🎯 第七课:聚类与分类算法——从”理解几何”到”做出决策”
为什么排第七?
第六课算出了每个顶点的曲率(K, H, thickness),但零散的数值对人没意义——用户不需要知道”第3742号顶点的高斯曲率是0.823”,用户需要的是”这个零件上有一个孔洞、两处凸台、一个薄壁区域”。

聚类 = 把零散数值变成有意义的区域
分类 = 给每个区域一个人类能懂的名字

用你的话说:”这就是’定义什么方案更符合人性’的算法化”(理念 1.1)——你定义了”什么是孔洞、什么是凸起”,AI 负责执行分类。

费曼学习法 Step 1:用大白话解释它是什么
想象你拿到了一张等高线地图:

地图上有无数个点,每个点有海拔数值(= 曲率值)
聚类:把海拔相同且相邻的点圈在一起 → “这是一座山”、”这是一片平原”、”这是一个盆地”
分类:给圈出来的区域命名 → “山”= 凸起、”盆地”= 孔洞、”平原”= 平面
在 Huhb3D 中:

每个三角形有曲率类别(第六课算的)
曲率类别相同 + 共享边的三角形聚在一起 → DFS 连通域聚类
根据聚类的面积、边界、曲率特征 → 几何特征分类(12类)
根据三角形法线朝向 → 法线方向分类(6类)
费曼学习法 Step 2:逐个拆解核心知识点
📦 A. 曲率分类——聚类的”黏合剂”
A1. 为什么需要曲率分类?

DFS 聚类需要判断”两个邻居三角形能不能合并到同一个聚类”——标准是曲率类别相同。但曲率是连续值,两个三角形的 K 值分别是 0.3 和 0.5,算不算”相同”?

解决方案:把连续曲率值离散化成6个类别,同类才合并。

A2. 六类曲率分类规则

| 类别 | 名称 | |K| | |H| | 3D打印直觉 |
|——|——|——|——|———–|
| 0 | 平面 | < 0.5 | < 0.5 | 几乎平坦,最容易打印 |
| 1 | 椭圆凸(dome) | > 2.0 | > 0.5 | 明显球面凸起 |
| 2 | 椭圆凹(bowl) | > 2.0 | < -0.5 | 明显碗面凹陷 |
| 3 | 圆柱凸/脊 | > 1.0 | > 0 | 急弯凸出 |
| 4 | 圆柱凹/谷 | > 1.0 | < 0 | 急弯凹入 |
| 5 | 混合/过渡 | 其他 | 其他 | 不确定区域 |

A3. 阈值怎么定?

阈值 数学含义 物理直觉
|H| < 0.5 法线变化率很小 几乎平坦
|H| > 1.0 法线变化率很大 急弯(法线几乎垂直)
|K| < 0.5 曲率积很小 没有明显凸/凹
|K| > 2.0 曲率积很大 明显球面特征
关键认知: 这些阈值不是从教科书抄的,是根据3D打印业务调出来的——K 的 2.0 和 H 的 1.0 不是数学真理,而是根据”什么算明显凸起”定的。

这就是”出题人”的权力——你定义”什么算平面”,AI 只负责执行判断。

AI坑点: AI 通常用 K > 0 做硬判断,没有阈值缓冲。3D扫描模型的数值噪声会让 K 在 0 附近抖动,硬判断会导致同一片平面上出现大量碎片聚类(一会儿正一会儿负),结果完全不可用。

📦 B. DFS 连通域聚类——核心算法
B1. 图论模型

把三角形网格看成一张图:

节点 = 三角形
边 = 两个三角形共享一条边(邻居关系,用第三课的边哈希表查询)
节点属性 = 曲率类别(0-5)
聚类 = 找出所有”曲率类别相同”的连通子图。

B2. DFS 完整流程(源码:render_manager.cpp:1195-1260)

// 前置:用第3课的边哈希表构建 边→三角形 映射
unordered_map<EdgeKey, vector, EdgeKeyHash> edge_to_triangles;

for (each triangle i) {
EdgeKey e1(tri[i].v1, tri[i].v2);
EdgeKey e2(tri[i].v2, tri[i].v3);
EdgeKey e3(tri[i].v3, tri[i].v1);
edge_to_triangles[e1].push_back(i);
edge_to_triangles[e2].push_back(i);
edge_to_triangles[e3].push_back(i);
}

// ──── 主聚类流程 ────
vector visited(N, false); // N = 三角形总数
vector clusters;

for (int i = 0; i < N; i++) {
if (visited[i]) continue; // 已经属于某个聚类

// 创建新聚类
Cluster cluster;
cluster.curvature_class = get_curvature_class(i);  // 第6课的K,H

// DFS 用 vector 当栈
vector<int> stack;
stack.push_back(i);
visited[i] = true;

while (!stack.empty()) {
    int curr = stack.back();
    stack.pop_back();
    cluster.triangles.push_back(curr);
    
    // 通过边哈希表找邻居
    Triangle& tri = triangles[curr];
    EdgeKey e1(tri.v1, tri.v2);
    EdgeKey e2(tri.v2, tri.v3);
    EdgeKey e3(tri.v3, tri.v1);
    
    for (EdgeKey e : {e1, e2, e3}) {
        auto it = edge_to_triangles.find(e);
        if (it == edge_to_triangles.end()) continue;
        
        for (int neighbor : it->second) {
            if (visited[neighbor]) continue;
            // ★ 核心决策:只扩展同类别
            if (get_curvature_class(neighbor) != cluster.curvature_class)
                continue;
            visited[neighbor] = true;
            stack.push_back(neighbor);
        }
    }
}

// 统计聚类信息
for (int tri_idx : cluster.triangles) {
    cluster.area += triangle_area(triangles[tri_idx]);
    cluster.avg_curvature += ...;  // K,H 加权平均
}
cluster.boundary_edges = count_boundary_edges(cluster);
clusters.push_back(cluster);

}
B3. ★ 核心设计:”只扩展同类别”

if get_curvature_class(neighbor) != cluster.curvature_class: continue;
这一行是整个聚类的核心决策——曲率类别不同的邻居不扩展,相当于在图上”竖了一堵墙”。

想象一片零件表面:

平面 平面 平面 │ 圆柱面 圆柱面 │ 平面 平面
0 0 0 │ 3 3 │ 0 0
↑ ↑
这里类别变了 这里又变了

DFS 从左上角出发 → 聚类A(3个平面三角形)
跳过圆柱面 → 继续DFS → 聚类B(2个圆柱面三角形)
跳过平面 → 继续DFS → 聚类C(2个平面三角形)

注意:A 和 C 都是平面,但它们不相连(中间隔了圆柱面),所以是两个独立聚类!
B4. vector 当栈——为什么不用 std::stack?

vector std::stack
底层容器 自己就是连续内存 默认用 deque(分段连续)
缓存友好 ✅ 连续内存,CPU预取 ❌ 分段跳跃
内存开销 无额外开销 deque 的分段管理开销
随机访问 可以 stack[i] 只能 top()
项目用 vector 而不是 std::stack,原因跟第四课的 BVH 线性化存储一样——缓存友好。

AI坑点: AI 默认用 std::stack,因为名字就叫”栈”。但 std::stack 默认用 deque 做 underlying container,在百万三角形级别下,分段内存的缓存惩罚可以慢 20-30%。

📦 C. 聚类边界检测——哪里是”墙”
C1. 边界边的定义

一个三角形属于某个聚类,但它的某条边对面的邻居属于另一个聚类(或者没有邻居)→ 这条边就是边界边。

聚类A │ 边界边 聚类B
─────────┼────────── ─────────
tri_0 │ ←这条边→ tri_5
tri_1 │ tri_6
C2. 边界统计的实现

int count_boundary_edges(Cluster& cluster,
unordered_map<EdgeKey, vector>& edge_map) {
// 用 unordered_set 做 O(1) 成员查询
unordered_set members;
for (int idx : cluster.triangles) members.insert(idx);

int boundary = 0;
for (int tri_idx : cluster.triangles) {
    Triangle& tri = triangles[tri_idx];
    for (EdgeKey e : {EdgeKey(tri.v1, tri.v2), 
                      EdgeKey(tri.v2, tri.v3), 
                      EdgeKey(tri.v3, tri.v1)}) {
        auto it = edge_map.find(e);
        if (it == edge_map.end() || it->second.size() == 1) {
            boundary++;  // 网格边界(只有1个三角形)
            continue;
        }
        for (int neighbor : it->second) {
            if (members.find(neighbor) == members.end()) {
                boundary++;  // 邻居不属于本聚类
                break;
            }
        }
    }
}
return boundary;

}
C3. unordered_set 查成员——O(1) 判断”是不是自己人”

为什么不直接用 vector 线性查找?O(N) vs O(1)——每个聚类可能有上万个三角形,每个三角形查3条边的邻居,线性查找太慢。

C4. 边界数量的业务含义

boundary_edges 少 boundary_edges 多 含义
凸起(dome) → 少边界 = 独立凸台;多边界 = 嵌入式凸起
凹陷(bowl) → 少边界 = 局部凹陷;多边界 = 可能是孔洞
面积小 + 多边界 → 孔洞的典型特征
边界数量是区分”孔洞”和”凹陷”的关键维度——两者曲率类别一样(都是椭圆凹),但边界形态不同。

📦 D. 法线方向分类——朝向决定可打印性
D1. 6种法线方向

类别 方向 判断条件 3D打印含义
0 斜面 maxComp ≤ 0.85 倾斜面,需适度支撑
1 +Y(朝上) maxComp > 0.98, Y分量最大且正 最容易打印,不需要支撑
2 -Y(朝下) maxComp > 0.98, Y分量最大且负 悬垂面,必须有支撑
3 ±X(侧面) maxComp > 0.98, X分量最大 需适度支撑
4 ±Z(侧面) maxComp > 0.98, Z分量最大 需适度支撑
5 近垂直 0.85 < maxComp ≤ 0.98 接近垂直,支撑需求取决于角度
6 近水平 maxComp < 0.85 悬垂面,翘曲风险极高
D2. 为什么阈值 0.98 和 0.85?

maxComp > 0.98:对应约 10° 偏移,法线几乎正对某轴 → 纯方向
0.85 < maxComp ≤ 0.98:对应约 30° 偏移 → 近方向
maxComp ≤ 0.85:偏移超过 30° → 斜面/悬垂
3D打印中,25°以内的悬垂角通常可以自支撑,超过就需要额外支撑结构。0.85 ≈ cos(30°) 正好对应这个业务临界。

又是一个”出题人”的决策——你定义了”什么角度算悬垂”,直接决定了支撑结构的用量和材料成本。

📦 E. 几何特征分类——12类零件特征
E1. 12类完整列表

类别 名称 判断条件 3D打印含义
0 小碎片 size < 5(三角形数量) 噪声/扫描伪影,可忽略
1 平面 |H| < 0.5, |K| < 0.5 主体面,最易打印
2 椭圆凸 K > 2.0, H > 0.5 明显球面凸起
3 椭圆凹 K > 2.0, H < -0.5 明显碗面凹陷
4 圆柱凸/脊 |K| > 1.0, H > 0 急弯凸出,注意层纹
5 圆柱凹/谷 |K| > 1.0, H < 0 急弯凹入,可能需支撑
6 鞍面凸脊 K < 0, H > 0 应力集中区
7 鞍面凹谷 K < 0, H < 0 容易开裂
8 凸起 K > 1.5, H > 0.5, A < 0.5 局部小凸起,注意过热
9 凹陷 K > 1.5, H < -0.5, A < 0.3 局部凹陷,可能积水
10 孔洞 H < -1.0, boundaryEdges ≥ 3 需要后处理(钻孔/攻丝)
11 凸台 H > 1.0, 0.1 < A < 2.0 局部加强筋
E2. 分类是”决策瀑布”——不是平铺的12个if

            ┌─── size < 5? ──→ 0: 小碎片
            │
            ├─── |H| < 0.5 && |K| < 0.5? ──→ 1: 平面
            │
            │    ┌─── H < -1.0 && boundaryEdges ≥ 3? ──→ 10: 孔洞
            │    │
            │    ├─── K > 1.5 && H < -0.5 && A < 0.3? ──→ 9: 凹陷
            │    │

曲率类型 ──────┤ └─── K > 2.0 && H < -0.5? ──→ 3: 椭圆凹

│ ┌─── H > 1.0 && 0.1 < A < 2.0? ──→ 11: 凸台
│ │
│ ├─── K > 1.5 && H > 0.5 && A < 0.5? ──→ 8: 凸起
│ │
│ └─── K > 2.0 && H > 0.5? ──→ 2: 椭圆凸

├─── |K| > 1.0 && H > 0? ──→ 4: 圆柱凸/脊

├─── |K| > 1.0 && H < 0? ──→ 5: 圆柱凹/谷

├─── K < 0 && H > 0? ──→ 6: 鞍面凸脊

└─── K < 0 && H < 0? ──→ 7: 鞍面凹谷
关键洞察: 分类不是”12个平等的选择”,而是有优先级的决策链——先检查全局风险(碎片、平面),再按曲率类型细分,最后用面积和边界做二次区分。

这就像医生诊断: 先查致命的(碎片=噪声),再查严重的(平面=主体),最后查具体的(凸起/凹陷/孔洞=局部问题)。

E3. “孔洞”vs”凹陷”——同一曲率类别,不同业务含义

两者都是 H < 0(凹面),区别在于:

凹陷(9) 孔洞(10)
H 值 H < -0.5 H < -1.0(更弯)
边界边 不限 boundaryEdges ≥ 3(几乎被包围)
面积 A < 0.3 不限
形状 局部碗面 小圆洞
打印影响 可能积水 需后处理(钻孔/攻丝)
业务严重度 中 高
这就是”定义问题”的价值——数学上它们都是凹面,但工程上完全不同。你作为出题人,用 H 的强度 + 边界边数量把它们区分开。

📦 F. 两套分类系统的关系——“黏合剂”vs”翻译器”
曲率分类(6类) 几何特征分类(12类)
角色 🧴 黏合剂 🌐 翻译器
作用 驱动 DFS 聚类(决定谁跟谁合并) 解释聚类含义(告诉用户这是什么)
输入 K, H(数学量) 面积 + 边界 + K + H + 法线
输出 同类三角形聚成区域 “孔洞”、”凸台”、”薄壁”
消费者 算法内部(DFS) 用户 / AI Agent
可理解性 不可理解(”椭圆凹”是什么?) 可理解(”孔洞”=要打洞的地方)
核心认知: 曲率分类是给机器看的,几何特征分类是给人看的。你同时设计了两种语言——一种让算法高效工作,一种让人能理解结果。

费曼学习法 Step 3:完整流水线
┌─────────────────────────────────────────────────────────────┐
│ 前置:第3课+第6课的成果 │
│ │
│ 边哈希表: EdgeKey → vector
│ 曲率: 每个顶点的 K, H, thickness │
│ 法线: 每个三角形的 normal[3] │
└──────────────────────┬──────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Step 1: 曲率分类 (每个三角形 → 0-5) │
│ │
│ K, H ──→ 阈值判断 ──→ curvature_type │
│ 0=平面, 1=椭圆凸, 2=椭圆凹, 3=圆柱凸, 4=圆柱凹, 5=混合 │
└──────────────────────┬──────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Step 2: DFS 连通域聚类 │
│ │
│ 只扩展同 curvature_type 的邻居 ──→ M 个 Cluster │
│ 每个 Cluster: {triangles, curvature_type, area, │
│ boundary_edges, avg_curvature, center} │
└──────────────────────┬──────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Step 3: 法线方向分类 (每个Cluster → 0-6) │
│ │
│ maxComponent > 0.98 → 纯方向 │
│ 0.85~0.98 → 近方向 │
│ < 0.85 → 斜面/悬垂 │
└──────────────────────┬──────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Step 4: 几何特征分类 (每个Cluster → 0-11) │
│ │
│ 决策瀑布: 小碎片? → 平面? → 曲率+面积+边界 → 12种特征 │
└──────────────────────┬──────────────────────────────────────┘


用户报告: “2个孔洞, 1处薄壁, 3个凸台, 1处悬垂”
AI Agent: “建议添加支撑于悬垂面区域,壁厚不足0.8mm”
费曼学习法 Step 4:识别你的知识缺口
# 自检问题 如果你答不出
1 DFS 只扩展同曲率类别,那两个同为”平面”但不相邻的区域会被合并吗? 重新理解”连通”——必须共享边,不只是同类别
2 为什么用 vector 当栈而不是 std::stack?性能差在哪? 重新理解缓存友好(第4课 BVH 线性化同理)
3 曲率分类的 K 阈值从 2.0 改成 0.5,聚类结果会怎样变化? 阈值越小→更多被判为”曲面”→聚类更碎片化
4 “孔洞”和”凹陷”的曲率类别一样,靠什么区分? H 的强度 + 边界边数量——这是你定义的决策规则
5 为什么几何分类要先检查”碎片”和”平面”,再按曲率细分? 优先级——全局特征 > 局部细节,这是架构思维
6 法线分类的阈值 0.85(cos30°)如果改成 0.7(cos45°),支撑结构用量会怎样? 阈值越低→更多面被判为”斜面”→支撑更多,但翘曲风险降低
7 一个聚类的 boundary_edges 很少但面积很大,最可能是什么特征? 平面(大面积 + 少边界 = 主体面)
费曼学习法 Step 5:用你的话复述
试着这样解释:

“3D模型几十万个三角形,每个都有曲率值。但用户不要数值,要的是’哪里有问题’。所以先把连续曲率离散化成6类(平面/椭圆凸/椭圆凹/圆柱凸凹/混合),曲率类别相同且共享边的三角形用 DFS 聚成区域——就像地图上把相邻的同色块圈出来。聚完后统计每个区域的面积、边界、曲率强度,通过决策瀑布分成12种工程特征:小碎片、平面、椭圆凸凹、圆柱面、鞍面、凸起、凹陷、孔洞、凸台。

两套分类系统各司其职:6类曲率是给 DFS 用的’黏合剂’,12类特征是给用户看的’翻译器’。同样的凹面,H强度一般就是’凹陷’,H强度很高且被边界包围就是’孔洞’——这之间的区别不是数学决定的,是你作为出题人定义的。”

📌 本课与你的 AI 时代理念的映射
你的理念 本课体现
1.1 当出题人 12类特征的定义、阈值的选取(K=2.0, H=1.0, boundary≥3)、决策瀑布的优先级——全是出题人的权力。”什么是孔洞”是你定义的
1.2 技术是切入点 DFS、unordered_set、vector当栈是技术,但”为什么需要聚类”是业务——用户要的是问题区域,不是数值
1.4 架构能力 决策瀑布是算法架构,两套分类系统是数据架构——每一层查什么、用什么精度,是架构师的设计
1.5 AI代码有坑 AI 用 std::stack(缓存差)、无阈值缓冲(碎片聚类)、平铺12个if(无优先级)——每个都”能跑但差很远”
1.6 软实力 “椭圆凹”是技术语言,”孔洞”是业务语言——翻译能力就是软实力,让你跟产品经理和客户对话
1.3 技术够用就可以 6类曲率不是数学最优(连续值更精确),但够用就行——聚类只需要”同类合并”,不需要精确度量