C#与OpenTK实战:从零构建轻量级STL 3D模型查看器

简介:本资源面向具备一定C#基础的.NET开发者与3D图形编程初学者,提供一套基于OpenTK实现STL格式3D模型显示、旋转与缩放的完整示例工程,可在Visual Studio 2015环境下直接编译运行,帮助读者理解OpenGL上下文创建、STL三角面片解析与实时交互渲染的完整链路。压缩包共44个文件,约642KB,以18个cs源码文件为核心,配合sln与csproj工程文件、config配置、resx资源及exe、dll等编译产物,目录结构清晰,便于对照调试。目前已有1927人学习下载。读者可从中获取GLControl初始化、二进制与文本STL解析、glTranslatef/glRotatef/glScalef变换、鼠标移动与滚轮事件驱动旋转缩放等关键实现,并在此基础上扩展光照、纹理贴图等效果,适合作为3D模型查看器入门与课程设计的参考范例。

1. 用 C# 和 OpenTK 把 STL 模型转起来:一个能直接跑的 3D 查看器

如果你手头有一堆 STL 文件,想快速看结构、转角度、量尺寸,又不想装几个 G 的桌面软件,那这个基于 C# 和 OpenTK 的 3D 模型查看器就是为你准备的。它用 OpenGL 渲染管线把 STL 三角面片画出来,支持鼠标拖拽旋转、滚轮缩放、右键平移,还能切换线框和实体模式。整个项目是纯 C# 写的,没有重型引擎依赖,编译出来就是一个轻量 exe。适合谁?正在学 OpenTK 图形编程的 C# 开发者、需要给自家设备做个简易模型预览的工控上位机开发者,以及想拿一个完整可运行项目练手 3D 数学的在校学生。下面我从项目结构、渲染管线、交互逻辑到二次开发,一层层拆开讲。

2. 项目结构与渲染管线:从 STL 文件到屏幕像素

2.1 目录组织与依赖说明

这个项目没有用任何 NuGet 包管理器之外的复杂依赖,核心就一个 OpenTK。常见做法是建一个 .NET 6 或 .NET Framework 4.7.2 的控制台转 Windows 窗体项目,然后通过 NuGet 引入 OpenTK 和 OpenTK.Graphics。目录结构大致如下:

StlViewer/ ├── Program.cs // 入口,初始化 GameWindow ├── MainWindow.cs // 主窗口,继承 GameWindow ├── Rendering/ │ ├── ShaderProgram.cs // 着色器编译与链接封装 │ ├── MeshRenderer.cs // VAO/VBO 管理,绘制调用 │ └── Camera.cs // 视图矩阵与投影矩阵计算 ├── Models/ │ ├── StlModel.cs // 模型数据容器 │ └── StlLoader.cs // STL 解析器(二进制+ASCII) ├── Input/ │ └── OrbitController.cs // 鼠标交互,旋转缩放平移 └── Shaders/ ├── vertex.glsl └── fragment.glsl

这个划分方式的好处是渲染、模型、输入三者解耦。你换一个模型格式,只改 Models 目录;换一种交互方式,只改 Input 目录。我一般会把着色器源码直接以字符串常量嵌在 C# 里,省去运行时读文件的路径问题,但如果你要频繁调着色器,还是放外部文件方便热重载。

2.2 STL 解析:二进制与 ASCII 的区分

STL 文件有两种格式:ASCII 和二进制。二进制格式开头 80 字节是头信息,紧接着 4 字节是三角形数量,之后每个三角形固定 50 字节(12 个 float 法向量和顶点 + 2 字节属性)。ASCII 格式则以solid开头,逐行读facet normal和vertex。解析时先读前 5 个字节判断是不是solid,但注意有些二进制文件头也恰好以solid开头,所以更稳妥的做法是检查文件长度是否等于 84 + 50 × 三角形数。

public static StlModel Load(string path) { using var fs = File.OpenRead(path); using var br = new BinaryReader(fs); // 先读 80 字节头 byte[] header = br.ReadBytes(80); uint triCount = br.ReadUInt32(); // 校验文件长度是否匹配二进制格式 long expected = 84 + 50L * triCount; if (fs.Length == expected) return LoadBinary(br, triCount); else { fs.Position = 0; return LoadAscii(fs); } }

这段代码的关键在于用文件长度做格式判定,而不是只看开头字符串。triCount是三角形数量,每个三角形解析出 3 个顶点坐标和 1 个法向量。解析完成后把所有顶点拼成一个float[]数组,法向量单独存或直接算面法线。注意 STL 没有索引,顶点是重复存储的,一个立方体 12 个三角形就有 36 个顶点。如果模型大,可以考虑用字典做顶点去重,但会牺牲加载速度。

2.3 OpenGL 渲染管线搭建

OpenTK 把 OpenGL 的调用封装成了 C# 方法,但底层逻辑不变。渲染一个 STL 模型需要:创建 VAO(顶点数组对象)、VBO(顶点缓冲对象)、编译着色器、设置顶点属性指针、在渲染循环里调用 DrawArrays。

// 创建 VAO 和 VBO _vao = GL.GenVertexArray(); GL.BindVertexArray(_vao); _vbo = GL.GenBuffer(); GL.BindBuffer(BufferTarget.ArrayBuffer, _vbo); GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.StaticDraw); // 位置属性:3 个 float,步长 6 * sizeof(float) GL.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), 0); GL.EnableVertexAttribArray(0); // 法线属性:偏移 3 个 float GL.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), 3 * sizeof(float)); GL.EnableVertexAttribArray(1);

这里每个顶点存 6 个 float:3 个位置 + 3 个法线。步长是 6 乘以 float 大小,位置偏移 0,法线偏移 3 个 float。着色器里顶点着色器负责把模型坐标乘以 MVP 矩阵,片元着色器用法线和光照方向做点积算漫反射。如果你只想要纯色显示,片元着色器直接输出固定颜色也行,但加了法线光照后模型立体感会强很多。

2.4 相机与 MVP 矩阵

相机用轨道模型:围绕模型中心的一个球面坐标,记录半径、水平角和俯仰角。视图矩阵用Matrix4.LookAt生成,投影矩阵用Matrix4.CreatePerspectiveFieldOfView。模型矩阵一般就是单位矩阵,除非你要对模型做额外变换。

var model = Matrix4.Identity; var view = Matrix4.LookAt(_camera.Position, _target, Vector3.UnitY); var proj = Matrix4.CreatePerspectiveFieldOfView( MathHelper.DegreesToRadians(45f), (float)Width / Height, 0.1f, 1000f); var mvp = model * view * proj; GL.UniformMatrix4(_shader.GetUniformLocation("uMvp"), false, ref mvp);

_camera.Position由球面坐标算出:target + radius * (cos(pitch)*cos(yaw), sin(pitch), cos(pitch)*sin(yaw))。俯仰角要限制在 -89 到 89 度之间,否则相机翻转会导致画面上下颠倒。近裁面 0.1 和远裁面 1000 要根据模型尺寸调整,模型特别大或特别小时不改这两个值会出现模型被裁掉或深度精度不够的问题。

3. 交互逻辑与显示模式:鼠标拖拽旋转缩放的实现细节

3.1 鼠标事件绑定与状态机

OpenTK 的 GameWindow 提供了MouseDown、MouseMove、MouseWheel事件。旋转用左键拖拽,平移用右键拖拽,缩放用滚轮。核心是维护一个状态变量记录当前按的是哪个键,然后在 MouseMove 里根据按键更新相机参数。

protected override void OnMouseDown(MouseButtonEventArgs e) { _lastMouse = e.Position; _isDragging = true; _dragButton = e.Button; } protected override void OnMouseMove(MouseMoveEventArgs e) { if (!_isDragging) return; var delta = e.Position - _lastMouse; if (_dragButton == MouseButton.Left) { _camera.Yaw += delta.X * 0.5f; _camera.Pitch -= delta.Y * 0.5f; _camera.Pitch = Math.Clamp(_camera.Pitch, -89f, 89f); } else if (_dragButton == MouseButton.Right) { _camera.Target += new Vector3(-delta.X * 0.01f, delta.Y * 0.01f, 0); } _lastMouse = e.Position; }

delta.X和delta.Y是相对于上一次鼠标位置的偏移量。乘以 0.5 是灵敏度系数,太大转得太快,太小转不动。俯仰角用Math.Clamp限制范围。右键平移时把屏幕像素偏移映射到世界坐标偏移,系数 0.01 需要根据相机距离动态调整,否则拉远后平移会变得很慢。

3.2 滚轮缩放与相机距离约束

滚轮事件里调整相机半径,同时要限制最小和最大距离,防止穿模或缩到看不见。

protected override void OnMouseWheel(MouseWheelEventArgs e) { _camera.Radius -= e.Delta * 0.5f; _camera.Radius = Math.Clamp(_camera.Radius, _modelBoundingRadius * 0.5f, _modelBoundingRadius * 10f); }

_modelBoundingRadius是模型包围球半径,加载完模型后算一次。最小距离设成半径的一半,最大距离设成半径的十倍。这样不管模型多大,缩放范围都合理。如果不做这个约束,滚轮滚太快会把相机推到模型内部,看到的就是一片黑或者反面。

3.3 线框与实体模式切换

按键盘 W 键切换线框模式,用GL.PolygonMode设置正面和背面的填充方式。

protected override void OnKeyDown(KeyboardKeyEventArgs e) { if (e.Key == Key.W) { _wireframe = !_wireframe; GL.PolygonMode(MaterialFace.FrontAndBack, _wireframe ? PolygonMode.Line : PolygonMode.Fill); } }

PolygonMode.Line只画三角形边线,PolygonMode.Fill填充实体。注意线框模式下如果开启深度测试,背面的线会被遮挡,看起来像实心线框。想要透视效果可以临时关闭深度测试,但会带来渲染顺序问题。我一般保留深度测试,线框模式用来检查网格拓扑就够了。

3.4 模型自动居中与缩放适配

加载完模型后,先算包围盒,把模型中心平移到原点,再根据包围球半径调整相机初始距离。

var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); foreach (var v in vertices) { min = Vector3.ComponentMin(min, v); max = Vector3.ComponentMax(max, v); } var center = (min + max) * 0.5f; var radius = (max - min).Length * 0.5f; // 把顶点坐标减去 center,让模型居中 for (int i = 0; i < vertices.Length; i += 6) { vertices[i] -= center.X; vertices[i + 1] -= center.Y; vertices[i + 2] -= center.Z; } _camera.Radius = radius * 3f;

这段代码遍历所有顶点找最小和最大坐标,算出中心和包围球半径。然后把每个顶点的位置减去中心,法线不用动。相机初始半径设成半径的三倍,保证模型完整出现在视野里。如果不做居中,模型可能偏在角落,用户得手动平移半天才能找到。

4. 避坑与常见问题:渲染黑屏、模型变形、性能卡顿

4.1 模型加载后黑屏或只看到背景色

现象:程序跑起来窗口是开的,但模型区域一片黑,旋转也没反应。原因通常是着色器编译失败或者 MVP 矩阵传错。先检查GL.GetShaderInfoLog有没有报错,再确认GL.UniformMatrix4传的矩阵是不是转置了。OpenTK 的矩阵默认是行主序,传给 GLSL 时transpose参数要设成 false,因为 GLSL 默认列主序,OpenTK 内部已经处理了。如果矩阵传成转置,模型会被压扁到看不见。解决:在GL.UniformMatrix4调用处加断点,看 mvp 矩阵的值是否合理,或者临时把片元着色器输出改成固定红色,确认几何体是否在画。

4.2 STL 解析后模型扭曲或面片错乱

现象:模型能显示,但形状明显不对,三角形乱飞。原因多半是二进制和 ASCII 格式判断错了,或者顶点读取顺序搞反。二进制 STL 每个三角形是 12 个 float 加 2 字节属性,如果按 ASCII 去读二进制文件,会读出乱码。解决:严格用文件长度校验,84 + 50 * triCount这个公式对二进制格式永远成立。另外注意字节序,STL 二进制是小端序,C# 的 BinaryReader 默认就是小端,不用额外处理。

4.3 大模型加载慢或旋转卡顿

现象:打开一个几十兆的 STL 文件,加载要十几秒,旋转时帧率掉到个位数。原因:STL 顶点重复存储,一个百万面的模型有 300 万个顶点,每个顶点 6 个 float,数据量很大。解决:加载时用字典做顶点去重,把重复顶点合并成索引缓冲,用DrawElements代替DrawArrays。另外把法线计算从 CPU 移到几何着色器或者用面法线代替顶点法线,能省不少内存。如果还卡,考虑把模型数据放到 GPU 显存后释放 CPU 端数组。

4.4 滚轮缩放方向反了或灵敏度不对

现象:滚轮往上滚模型变小,往下滚变大,跟直觉相反。原因:MouseWheelEventArgs.Delta的正负号在不同平台或不同 OpenTK 版本里可能不一致。解决:先打印e.Delta的值,确认正负,然后在代码里加负号调整。灵敏度系数 0.5 也不是固定的,模型半径大时应该调小,半径小时调大,可以写成e.Delta * _camera.Radius * 0.001f让缩放速度跟距离成正比。

4.5 窗口大小改变后模型拉伸变形

现象:拖动窗口边缘改变大小,模型被拉长或压扁。原因:投影矩阵的宽高比没有跟着窗口尺寸更新。解决:在OnResize事件里重新计算投影矩阵,把Width / Height传进去。同时调用GL.Viewport(0, 0, Width, Height)更新视口。如果用了帧缓冲或者多视口,每个视口都要单独设置。

5. 二次开发与改造:从查看器到业务工具

5.1 加一个模型尺寸测量功能

最实用的改造是加测量。思路:在鼠标点击时发射一条射线,跟模型三角形求交,记录两个交点的世界坐标,算距离。射线用Matrix4.Invert(mvp)把屏幕坐标反投影到世界空间。求交用 Möller–Trumbore 算法,遍历所有三角形。代码量不大,但要注意性能,可以先用包围盒粗筛。

// 屏幕坐标转世界射线 var invMvp = Matrix4.Invert(mvp); var near = UnProject(new Vector3(mouseX, mouseY, -1), invMvp); var far = UnProject(new Vector3(mouseX, mouseY, 1), invMvp); var dir = Vector3.Normalize(far - near); // 遍历三角形求交,记录最近交点

UnProject把 NDC 坐标乘逆矩阵再除 w 分量。测距时点两个点,存下交点坐标,画一条线并显示距离数值。这个功能在工控场景里很常见,比如检查零件尺寸。

5.2 批量截图与视角预设

如果要做报告,可以加一个按键触发截图,把当前帧缓冲保存成 PNG。用GL.ReadPixels读像素,再用System.Drawing或ImageSharp存文件。视角预设就是存几组相机参数,按数字键切换。比如按 1 切到正视图,按 2 切到俯视图,按 3 切到等轴测图。实现时把 yaw、pitch、radius 存成结构体数组,按键时直接赋值。

5.3 嵌入 WinForms 或 WPF 上位机

OpenTK 的 GameWindow 是独立窗口,如果要嵌到 WinForms 里,用GLControl控件代替。在 WinForms 设计器里拖一个 GLControl,把渲染循环挂到Application.Idle事件上。WPF 的话用WindowsFormsHost包一层。注意 GLControl 的上下文要在Load事件里初始化,Resize事件里更新视口。这样就能把 3D 查看器集成到现有的工控软件里,跟按钮、文本框混排。

5.4 性能优化与模型简化

大模型优化除了顶点去重,还可以做 LOD(细节层次)。根据相机距离切换不同精度的模型,远处用简化版,近处用完整版。简化算法可以用二次误差度量,但实现复杂。简单做法是按三角形面积排序,丢掉最小的百分之多少。另外开启背面剔除GL.Enable(EnableCap.CullFace)能省一半片元着色器调用。深度测试GL.Enable(EnableCap.DepthTest)一定要开,否则面片渲染顺序错乱。

5.5 我踩过的坑与验证习惯

有一次我改完着色器忘了重新编译,程序跑起来还是旧效果,查了半天以为是矩阵问题。从那以后我每次改完着色器都强制走一遍编译日志检查,把GL.GetShaderInfoLog的输出打到控制台,确认没有警告才继续。还有一次模型加载后位置偏了,原因是包围盒计算时把法线也当成位置遍历了,步长写错。现在我的习惯是:加载完模型先打印包围盒中心和半径,跟预期对一下,不对就断点看顶点数据。这些检查花不了几分钟,但能省掉大量玄学调试时间。希望帮到你。

本文还有配套的精品资源,点击获取

相关新闻

AI科研写作助手深度体验:六大平台评测与智能润色实战

AI科研写作助手深度体验:六大平台评测与智能润色实战

做科研这些年&#xff0c;我最深的体会是&#xff1a;实验做得再漂亮&#xff0c;表达不清楚&#xff0c;成果价值起码打七折。最近圈子里好几个人都在提“某AI毕业助手”这个科研写作辅助产品&#xff0c;宣传点集中在“六大顶尖平台评测”“智能润色”“高效创作”这几件事上…

2026/10/22 10:42:26 阅读更多 →
InnoDB预读机制详解:从Buffer Pool命中率到性能调优

InnoDB预读机制详解:从Buffer Pool命中率到性能调优

在做MySQL性能调优时&#xff0c;InnoDB Buffer Pool的命中率永远是我最先看的指标之一&#xff0c;而“预读”这个藏在Buffer Pool背后的机制&#xff0c;往往被很多人忽略。说句实话&#xff0c;早期我也只把预读当成一个默认配置&#xff0c;直到一次报表业务的磁盘IO被打满…

2026/10/22 11:03:10 阅读更多 →
深度解析InnoDB Buffer Pool:从工作原理到性能调优实战

深度解析InnoDB Buffer Pool:从工作原理到性能调优实战

1. Buffer Pool在解决什么问题&#xff1a;一次磁盘读页的代价很多刚接触MySQL内部机制的朋友&#xff0c;对Buffer Pool的第一印象是"一块缓存内存&#xff0c;用来加速查询的"。这个说法没错&#xff0c;但它掩盖了一个关键问题&#xff1a;为什么偏偏是它&#xf…

2026/10/22 9:45:23 阅读更多 →

最新新闻

Electron桌面应用开发实战:从环境搭建到打包发布全流程

Electron桌面应用开发实战:从环境搭建到打包发布全流程

做前端这些年&#xff0c;我大部分时间都在跟浏览器标签页打交道&#xff0c;直到第一次把桌面应用跑起来&#xff0c;才发现原来自己一直被限制在地址栏和刷新按钮围起来的框框里。Electron这个词&#xff0c;最开始是在某段时间频繁出现在同事的讨论里&#xff0c;一个用HTML…

2026/10/22 10:18:38 阅读更多 →
滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书是一份PDF格式的官方技术文档&#xff0c;主要面向智能锁安装人员、终端用户及售后维护者&#xff0c;帮助快速掌握产品安装、配置与常见故障处理。全包仅1个PDF文件&#xff0c;大小4.37MB&#xff0c;内容结构清晰&#xff…

2026/10/22 11:01:53 阅读更多 →
PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

上个月我在调试一台高速AGV的驱动系统&#xff0c;整车要求空载跑到8m/s&#xff0c;电机转速已经拉到额定点&#xff0c;驱动器的输出电压也顶到了母线电压极限附近。再往上踩速度指令&#xff0c;转速纹丝不动&#xff0c;三相电流反而开始震荡。那会儿我的第一反应就是&…

2026/10/22 9:44:47 阅读更多 →
C++核心知识点:从内存管理到STL的实战指南

C++核心知识点:从内存管理到STL的实战指南

C 这门语言我前前后后折腾了十几年&#xff0c;用它写过嵌入式驱动、做过游戏服务端&#xff0c;也搭过基础组件库。每次看到新人一头扎进"C 核心知识点"这个搜索词&#xff0c;我其实挺感慨的——这门语言的难点从来不是语法多&#xff0c;而是知识点之间的耦合太深…

2026/10/22 10:42:00 阅读更多 →
T109滑盖智能锁怎么装?安装调试与故障排查全攻略

T109滑盖智能锁怎么装?安装调试与故障排查全攻略

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书以PDF格式发布&#xff0c;适合智能锁用户、安装人员与售后维修人员阅读。内容围绕T109系列指纹密码刷卡锁展开&#xff0c;先介绍前面板、后面板、锁体、机械钥匙、电池盒盖等部件名称与功能&#xff0c;再给出完整安装步骤&…

2026/10/22 10:40:08 阅读更多 →
LLM知识降熵:从信息垄断到理解权下放

LLM知识降熵:从信息垄断到理解权下放

你有没有过这种经历&#xff1a;翻开源码注释、论文摘要或者一份专业报告&#xff0c;每个字都认识&#xff0c;连在一起却像天书。想找人聊聊&#xff0c;身边要么没人懂&#xff0c;要么懂的懒得跟你解释。后来我试着把同一段内容丢给一个大语言模型&#xff0c;几秒钟之后&a…

2026/10/22 10:56:37 阅读更多 →

日新闻

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/22 1:47:45 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/21 21:32:28 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/22 5:12:39 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 21:50:56 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 21:51:55 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 16:24:48 阅读更多 →