← 图形原理 · 交互课 课题十 · 几何处理 · 第 4 课

半边结构:把规则用到任意拓扑上要记的账

全课题骨架:几何不是"画"出来的,是控制点 + 一条可以反复施加的规则算出来的 —— 而那条规则只做一件事:在两点之间取一点。
前三课一直在说"任意拓扑":第 1 课把规则用在一排点上,第 2 课把它装到一个环上, 第 3 课把它在两个方向各用一次。可"一个环"是从哪儿来的?第 2 课的 topology() 是 每次要遍历就现算一遍的临时账本 —— 而真实建模里网格要反复编辑,这份账必须存下来。
四段路:把每条边拆成两条半边、记下四样账(23.1)→ 走指针就能绕一圈,邻域不用再重建(23.2)→ 看清这份账什么时候根本记不下去(23.3)→ 把邻域用起来,算出一个与拓扑无关的几何量(23.4)。
★ 量出来才敢写的一件事:"任意拓扑"是有前提的 —— 一条边被三个面共用、一个顶点周围的面分成两片、 或者曲面像 Möbius 带那样不可定向,这份账就建不起来;三种情形本页都造了反例, 各自被拒在不同的那一条上。 以及:一个顶点周围的角度和能告诉你那一点的弯曲 —— 立方体每个顶点亏 π/2、八个顶点合起来恰好 4π (实测与 2πχ 之差 0)。 保真度:示意 · 等价重现 —— 半边表、邻域遍历、角亏损、余切权都是纯算术 (页面现场跑的十条与离线定稿 q204.mjs 同口径),画面是本页自己的二维软件光栅 (正交投影 + 平面着色 + 线框),不是任何网格库的内部结构。 ★ 本页的边界处 twin = -1(不补"虚拟边界面"那一圈)—— 一手源里两派做法都有, 本页取前者并如实标注;也不做半边结构的编辑算子(split / collapse / flip)。

分段:
网格:
绕圈看谁: (脚本启动中…)
显示: |拖画面可以转(正交投影)

(脚本启动中…)

定点观察:(脚本启动中…)

本帧的读数

(脚本启动中…)

23.1 段 · 一条边拆成两条

本段只增加一样东西:把每条边拆成两条半边(读者只动「绕圈看谁」的滑块 —— 它在这一段选的是"哪一条半边")。 保真度:示意 · 等价重现。前三课一直在说"任意拓扑",可第 2 课那个 `topology()` 是 每次要遍历就现算一遍的临时账本;而真实建模里网格要反复编辑,"这条边的邻居是谁"会被问上千次 ⇒ 这份账必须存下来。存法只有一行:每条边拆成两条方向相反的半边,每条半边记四样 —— v(起点)、next(同一面里的下一条)、twin(反向的那条)、 f(左侧面)。
★ 全局只有一条约定:面在半边的左侧,于是绕面是逆时针(一手源原话:"we look at the surface such that the halfedges around a face are oriented counterclockwise")。这条约定一改,所有指针的语义都要跟着改。
★ 账记完就有两条能逐条验的不变量(与一手源那条等价):h.twin.v === h.next.v、 h.twin.next.v === h.v —— 立方体 24 条半边全部成立。

输入 顶点 + 面 面按一致朝向 环着写 要存的账 v 起点 next 同面下一条 twin 反向那条 f 左侧面 一条约定 面在半边左侧 ⇒ 绕面 CCW 见判据① ① 半边表:把每条边拆成两条(本页真跑的几行) function buildHalfEdge(verts, faces) { for (const F of faces) for (let k = 0; k < F.length; k++) { const u = key2(F[k], F[(k + 1) % F.length]); cnt.set(u, (cnt.get(u) || 0) + 1); } // ① 先数无向边 // ★ 一条边被 3 个面共用 ⇒ 这里就抛「边」 for (let f = 0; f < faces.length; f++) { const F = faces[f], n = F.length, base = H.length; for (let k = 0; k < n; k++) { const a = F[k], b = F[(k + 1) % n], key = a * NV + b; dir.set(key, base + k); // 有向边 → 半边 H.push({ v: a, f: f, next: base + (k + 1) % n, twin: -1, target: b }); } // ② 同一面里 next 成环 heOfFace[f] = base; } // …③ 连 twin:找不到反向 // ⇒ 边界半边 twin = -1 // …④ 顶点星分片 ⇒ 抛「顶点」;否则 return he } 右列读数 V/E/F 8 / 12 / 6 χ = 2 半边数 = 2E 24 = 2 × 12 ✓ I1 与 I2 24 / 24 全部 ✓ Σdeg = 2E 24(每点度 3) 边界半边 0 见判据①②
件一:23.1 的半边表。左列是要存的四样账与那条唯一约定,中间是本页真跑的建表源码 (// … 那三行是省略标记,其余每行都与本页源码逐字相同),右列是实测。 注意中间那两行 v: a 与 next: base + (k + 1) % n —— 一条半边"从哪来、往哪去"全在这两处。
件三 · 表① 立方体上的半边账(12 条边 → 24 条半边)。每一条都是可手算的: 8 个顶点每个度 3 ⇒ Σdeg = 24 = 2E;两条不变量逐条半边都在验。
记的账应然实测结论怎么算类别

定点观察:① 拖「绕圈看谁」的滑块(这一段它选的是"哪一条半边"); ② 看画面里每条边被两条方向相反的单线共用(一条偏高亮、一条偏暗,末端各有一个点表示方向); ③ 结论:半边数恰好是边数的两倍,而每条半边都有唯一的下一条与唯一的反向 —— "邻居是谁"从"现算"变成了"顺着指针读"。

23.2 段 · 走指针绕一圈

本段只增加一样东西:用指针遍历(读者只动「绕面 / 绕顶点」那对按钮与滑块,不再重建任何账本)。 保真度:示意 · 等价重现。上一段把账存下来了,可"存"本身不是目的 —— 目的是用它回答邻居问题。 只需要两个动作:h = h.next 绕一张面走一圈(面里几条边就走几步), h = h.twin.next 绕一个顶点走一圈(那个顶点度数是几就走几步)—— 一手源原话:"successive assignments of the form h = h->next() cycle counterclockwise around the facet … successive assignments of the form h = h->next()->opposite() cycle clockwise around the vertex"。
★ 本段的账是两条独立路线:一条是"顺着指针绕圈"收集邻点 / 邻面 / 邻边,另一条是 "直接扫描 faces 数组"收集同一批东西 —— 两者不共用任何中间量,立方体 8 个顶点逐元素完全相同。
★ 顺带一条诚实的限制:有边界时 twin 为空,绕顶点会断。一手源里两派做法都有 (把边界标成"没有面"/补一圈"虚拟边界面"让它也能绕);本页取前者,并在开放网格上只报 "边界半边数 = 边界环长"这一条。

输入 半边的四样账 v / next / twin / f 已按一致朝向建好 两个动作 h = h.next 绕一张面一圈 h = h.twin.next 绕一个顶点一圈 终止 回到起点那条 (步数即答案) 见判据③④⑤ ② 两个绕圈动作(本页真跑的几行) function ringFace(he, f) { // 绕面 const out = []; let h = he.heOfFace[f], n = 0; do { out.push(h); h = he.H[h].next; n++; } while (h !== he.heOfFace[f] && n < 64); return out; } // 步数 = 面边数 function ringVert(he, v) { // 绕顶点 const out = []; let h = he.heOfVert[v], n = 0; if (h < 0) return out; do { out.push(h); if (he.H[h].twin < 0) break; // 边界半边 h = he.H[he.H[h].twin].next; // ★ twin 再 next n++; } while (h !== he.heOfVert[v] && n < 64); return out; } // 步数 = 度数 ★ 全程只读指针,一次都没碰过 faces 数组 右列读数 绕面 6 / 6 个面 步数 = 边数 ✓ 绕顶点 8 / 8 个顶点 步数 = 度 3 ✓ 两条路线 8 / 8 逐元素 完全相同 顶点 6 的邻点 [2, 5, 7] 见判据③④⑤
件一:23.2 的两个绕圈动作。左列是输入与两个动作,中间是本页真跑的两个函数 (逐字),右列是实测。注意下半段那一行 h = he.H[he.H[h].twin].next —— 本课的关键就是这一句:先退回那条反向半边,再走它的 next,于是从"一条边"转到了"下一条边"。
件三 · 表② 立方体上的两次绕圈。两条路线是真正独立的:一条只读指针,一条只扫 faces 数组; 它们给出同一组邻域,才说明这份账记对了。
量什么应然实测结论怎么走类别

定点观察:① 在「绕面(next)」与「绕顶点(twin·next)」之间切换,并拖滑块换起点; ② 看画面里高亮的那一圈怎么走(面环一种颜色、顶点环另一种);③ 结论:两次都恰好走回起点, 步数分别等于面边数与顶点度数 —— 而且这一圈走完,你就拿到了原来要"重建整张账本"才能拿到的邻域。

23.3 段 · 这份账什么时候记不下去

本段只增加一样东西:把网格换成"不满足前提"的那种(读者只动「网格」那一排按钮)。 保真度:示意 · 等价重现。前两段一路顺风,是因为默认了一个前提:每条边至多两个面、每个顶点周围连成一片 —— 也就是一手源说的 2-manifold("a surface that when cut with a small sphere always yields a disk"), 以及半边结构只能表示可定向流形(CGAL 手册:"the variant here is restricted to orientable 2-manifolds only")。
★ 本段造了三个反例,它们分别踩在三条不同的检查上(这就是这段最值钱的地方): ① 三个面共用一条边 ⇒ 一条边数出 3 个面 ⇒ 报「边」; ② 两个面只共用一个顶点(沙漏)⇒ 那个顶点周围的面分成两片、绕不成一圈 ⇒ 报「顶点」; ③ Möbius 带 ⇒ 曲面不可定向,一条有向边被两个面同向用了两次 ⇒ 报「定向」。
★ 并且如实标注:开放网格(有边界)是合法的流形,只是边界处 twin = -1; 本页那条"顶点星要连成一片"的判法特意不依赖 twin(有边界时用 twin 会漏判), 所以开放网格照样建得起来。

前提 2-流形 + 可定向 每条边 ≤ 2 个面 顶点星连成一片 三种反例 ① 边:3 个面 ② 顶点:星两片 ③ 定向:Möbius 合法但特殊 有边界:twin = -1 χ = 1(圆盘) 读数 被拒在哪一条 见判据⑦⑧ ③ 三处拦截:踩在不同的前提上(本页真跑的几行) for (const [u, c] of cnt) if (c > 2) { const e = new Error('一条边被 ' + c + ' 个面共用'); e.kind = '边'; e.at = [Math.floor(u / 4096), u % 4096]; throw e; // ① 非流形·边 } if (dir.has(key)) { const e = new Error('有向边 ' + a + '→' + b + ' 出现了两次'); e.kind = '定向'; throw e; // ③ 不可定向 } if (roots.size > 1) { const e = new Error('顶点 ' + v + ' 周围的面分成 ' + roots.size + ' 片'); e.kind = '顶点'; throw e; // ② 非流形·顶点 } 右列读数 流形 4 个 立方体 8/12/6 四面体 4/6/4 二十面体 12/30/20 开放带 8/10/3 χ=1 反例 3 个 边 3 面 ⇒「边」 星两片 ⇒「顶点」 Möbius ⇒「定向」 边界半边 8 = 环长 见判据⑦⑧
件一:23.3 的三处拦截。左列是前提与三种反例,中间是本页真跑的三段判断 (逐字,省略号那几行是别处的行),右列是四个流形网格与三个反例的实测。 三处拦截分别落在边、顶点、定向上 —— 这也是这段唯一要带走的一句话: "任意拓扑"其实是"任意可定向流形拓扑"。
件三 · 表③ 七个网格各建一次半边表。前四个应当建得起来(κ 一栏是 χ), 后三个应当被拒——而且拒在哪一条上各不相同。末行是开放网格上的 Gauss–Bonnet: 边界顶点要用 π − Σθ(离散测地曲率),加起来仍是 2πχ。
网格V / E / Fχ半边 / 边界半边结果类别

定点观察:① 在「网格」里依次点七个;② 看后三个各自停在哪一步 (读数行会把类别与原因写出来,画面只画得出网格线,因为半边表根本没建起来); ③ 结论:"任意拓扑"有前提 —— 一条边三个面、顶点星分两片、曲面不可定向, 这三种都不在这份账能表示的范围内;而开放网格是合法的,只是边界处没有反向半边。

23.4 段 · 有了邻域就能算曲率

本段只增加一样东西:把邻域用起来,算一个数(读者只动滑块换顶点)。 保真度:示意 · 等价重现。前三段都在记"谁挨着谁",可记这个干什么用? 最干净的一个用途是角亏损:把某个顶点周围所有面的内角加起来,看它离一整圈(2π)差多少 —— Ω = 2π − Σθ。平坦的地方恰好差 0;立方体每个顶点差 π/2;正四面体差 π; 正二十面体差 π/3。而这一切有个漂亮的守恒:ΣΩ = 2πχ(离散 Gauss–Bonnet)—— 立方体八个顶点合起来恰好 4π(实测与 2πχ 的差是 0)。
★ 第二视角(同一份邻域,换个权):把"均匀地平均邻居"换成余切权 w_ij = (cot α + cot β)/2(α、β 是这条边两侧的对角)。于是平坦网格上 线性函数会被打成恰好 0(1.12e−16),规则网格上的权重只剩 三个取值(0 / 0.5 / 1,都能手算), 而单位球面上 |L| / (2|p|) → 2 且方向与 p 相反。 ★ 余切权的已知缺陷也一并量出来:钝角 ⇒ 负权(一手源原话:"the cotangent becomes negative for angles between 90 and 180 degrees")——本页那个钝角靶子给出 w = −24.9975。

输入 一个顶点的邻域 周围那张扇形 (前两段记下的账) 角亏损 Ω = 2π − Σθ 平坦 0 | 立方体 π/2 四面体 π | 二十面体 π/3 守恒 ΣΩ = 2πχ 立方体 8×π/2 = 4π 第二视角 w = (cotα+cotβ)/2 钝角 ⇒ 负权 见判据⑨⑩ ④ 角亏损与余切权(本页真跑的几行) function vertexAngleSum(verts, faces) { const s = new Float64Array(verts.length); for (const F of faces) { const n = F.length; for (let k = 0; k < n; k++) s[F[k]] += angleAt(verts, F[k], F[(k + 1) % n], F[(k + n - 1) % n]); } // ★ 不是 k+2 return s; } function angleDeficit(verts, faces) { return Array.from(vertexAngleSum(verts, faces), (x) => TAU - x); } // Ω = 2π − Σθ function cotanW(verts, faces) { // 第二视角 for (const F of faces) { if (F.length !== 3) continue; for (let k = 0; k < 3; k++) { const i = F[k], j = F[(k + 1) % 3], l = F[(k + 2) % 3]; const u = key2(i, j); W.set(u, (W.get(u) || 0) + 0.5 * Math.cos(angleAt(verts, l, i, j)) / Math.sin(angleAt(verts, l, i, j))); } } } 右列读数 每顶点 Ω 立方体 1.570796 四面体 3.141593 二十面体 1.047198 ΣΩ vs 2πχ 差 0.00e+0 (立方体) 平坦网格 内部 Ω = 0 余切权 0 / 0.5 / 1 钝角 −24.9975 见判据⑨⑩
件一:23.4 的角亏损与余切权。左列是输入(前一课记下的邻域)与两个读数,中间是 本页真跑的函数,右列是实测。中列那一行 F[(k + n - 1) % n] 是本课修过的一处: 三角形的 k+2 恰好等于 k−1,四边形上两者不等价(见设计稿 §六)。
件三 · 表④ 角亏损。前四行每行的"每个顶点都一样"是可手算的(立方体 3 个直角 ⇒ 2π − 3π/2 = π/2); 末行是平坦网格:内部顶点周围的角度和恰好是一整圈 ⇒ 亏损恰好 0。
网格每顶点 Ω顶点数ΣΩ2πχ差
件三 · 表⑤(第二视角)余切权。前四行是规则网格上权重只有三个取值(都能拿笔算: 对角边两侧对角都是 90° ⇒ cot 为 0;边界边只有一侧有面 ⇒ 减半;内部轴对齐边两侧对角各 45° ⇒ cot = 1)。 末两行是球面上的收敛与钝角靶子。
算什么实测说明规模目标类别

定点观察:① 拖滑块换顶点,并切换到另外几个网格;② 看那个顶点周围被高亮的角度扇形 (每一对青色线就是一个三角形的内角);③ 结论:角亏损只依赖"这个顶点周围有几个面、每个面在那里张开多大" —— 它是内蕴的:把网格揉成一团也好、摊平也好,只要不撕开,每一个顶点的角亏损都不变; 而对整张闭合网格,它们加起来恰好是 2πχ(与网格长什么样无关,只与拓扑有关)。

本页用到的做法,出处在哪

保真度与不做的部分:半边表、邻域遍历、角亏损、余切权全部由本页自己的代码算出 (页面现场与离线定稿 q204.mjs 同口径),画面是本页的二维软件光栅(正交投影 + 平面着色 + 线框), 不声称与任何网格库(CGAL / OpenMesh / libigl)的内部结构逐位相同。 ★ 本页的边界处理取"twin = -1"这一派,不补"虚拟边界面",因此开放网格上不做绕顶点一圈的断言 (如实标注);也不做半边结构的编辑算子(split / collapse / flip)—— 那是这个数据结构的另一半用途,本页只交付"读"的那一半。均值曲率用的顶点面积是三分之一面积, 不是 Meyer 的 mixed Voronoi 面积(页面上已标明)。

自检面板

等待脚本…