ColorYourModel 技术架构说明书
| 技术栈 | Tauri 2 + React 18 + TypeScript + Three.js / React-Three-Fiber + Rust |
|---|---|
| 文档日期 | 2026-08-06 |
| 基线分支 | feat_theme_persist_paint_ux |
| 说明 | 所有行数经 wc -l 实测;本文档取代 README 中已过时的架构描述 |
| 核对状态 | 2026-08-06 经两路独立 agent 逐条对照源码证伪。已修订 5 处 blocker:MeshModel.segments 类型、smart_brush_paint 参数、finalize_manual_region 参数、export_3mf_command 返回类型、logger.ts 路径。行数(33 个文件)、常量数值(11 项)、IPC 调用点(18 处)、架构约束(2 条)经实测零偏差 |
一、总体数据流
用户选择 STL
│
▼ invoke("load_model")
[Rust] loader.rs 解析 → 顶点去重 → 面邻接图(petgraph) → KD-Tree(kiddo)
→ face_colors 全量填 #8a8a8a → segment_labels = vec![0; face_count]
│
▼ MeshDataDto (serde camelCase)
[TS] useMesh.buildGeometry → toNonIndexed() → BufferGeometry → zustand
│
├─► 自动分区:invoke("auto_segment_smart" | "auto_segment")
│ [Rust] sdf.rs / dihedral.rs → SegmentResult
│ → updateSegmentLabels 双写 segments + meshData.segments
│
├─► 手动分区:invoke("manual_region_add_point") × N
│ → invoke("finalize_manual_region")
│ [Rust] manual.rs Dijkstra 加密 + 障碍 BFS + 形态学平滑
│
├─► 上色:usePaintTool.paintFace 分派
│ → invoke("fill_paint" | "brush_paint" | "spray_paint" | ...)
│ [Rust] KD-Tree 命中 + mix_color → PaintResult{updatedFaces, updatedColors}
│ → useMesh.updateFaceColors 增量写 GPU(不重建几何)
│
├─► 撤销:invoke("restore_face_colors")
│
▼ invoke("export_3mf_command")
[Rust] threemf.rs → quick-xml 生成 + zip 打包 → 3MF 文件
│
▼
OrcaSlicer / BambuStudio 切片打印
二、模块清单
2.1 Rust 后端(src-tauri/src)
| 路径 | 职责 | 行数 | 关键导出 |
|---|---|---|---|
lib.rs |
注册 AppState 与 18 个命令 | 44 | run() |
commands/mesh.rs |
模型加载、单面查色 | 84 | load_model, get_face_color, AppState{Mutex<Option<MeshModel>>} |
commands/paint.rs |
7 类绘制命令 | 214 | PaintResult, brush_paint, fill_paint, fill_segment_paint, spray_paint, smart_brush_paint, erase_paint, pick_color |
commands/segment.rs |
自动/手动分割命令 | 346 | SegmentResult, auto_segment, auto_segment_smart, paint_segment_face, finalize_segment, manual_region_add_point, finalize_manual_region, manual_region_undo, restore_face_colors |
commands/export.rs |
导出入口 | 17 | export_3mf_command |
mesh/model.rs |
核心网格结构与方法 | 417 | MeshModel, Segment, BoundingBox, ManualRegionSnapshot, MeshDataDto |
mesh/loader.rs |
STL 解析、去重、建索引 | 102 | load_stl(接收 &ProgressFn 回调;import-progress 事件实际在 commands/mesh.rs:41,53 发射) |
mesh/kdtree.rs |
空间索引封装 | 10 | distance |
mesh/face_colors.rs |
颜色混合 | 13 | mix_color |
segment/sdf.rs |
SDF 形状直径分割 | 620 | compute_sdf, segment_by_sdf, estimate_k, kmeans_1d |
segment/dihedral.rs |
二面角分割与合并 | 503 | segment_by_dihedral_angle, normal_consistency_merge, merge_small_regions_fast |
segment/manual.rs |
套索环、吸附、快照 | 892 | region_from_loop, snap_point_to_vertex_on_face, finalize_manual_region, smooth_region_boundary |
segment/flood_fill.rs |
BFS 泛洪 | 51 | flood_fill |
paint/brush.rs |
笔刷命中与衰减 | 43 | brush_hit, falloff_strength |
paint/fill.rs |
填充实现 | 26 | fill_region, fill_segment |
paint/spray.rs |
喷枪(LCG PRNG 采样) | 55 | spray_hit |
paint/smart_snap.rs |
智能笔法线裁剪 | 64 | smart_brush_hit |
export/threemf.rs |
3MF 写出 | 211 | export_3mf |
上表为 18 个实现文件。后端 .rs 文件实际共 24 个,另 6 个为纯声明文件,无实现逻辑:main.rs(6)、commands/mod.rs(4)、export/mod.rs(1)、mesh/mod.rs(8)、paint/mod.rs(4)、segment/mod.rs(4)。
README 勘误:README 中列出的
paint/eraser.rs实际不存在(橡皮在commands/paint.rs内实现);README 未列出segment/sdf.rs、segment/manual.rs、mesh/kdtree.rs、paint/smart_snap.rs、paint/spray.rs。
2.2 React 前端(src)
| 路径 | 职责 | 行数 | 关键导出 |
|---|---|---|---|
components/Viewport/Viewport.tsx |
渲染、交互、相机、拾取 | 2141 | overModelRef, applyCameraButtons, CameraFit, MeshDisplay, ControlsBridge |
store/appStore.ts |
zustand + persist 全局态 | 437 | mergePrefs, applyFaceColors, updateSegmentLabels |
hooks/useTauriCommand.ts |
IPC 封装层 | 289 | loadModel, autoSegment, export3mf 等 |
hooks/useMesh.ts |
几何构建与增量更新 | 245 | buildGeometry, updateFaceColors |
hooks/usePaintTool.ts |
工具分派 | 217 | paintFace |
components/Toolbar/Toolbar.tsx |
工具栏 | 225 | — |
components/BrushSettings/BrushSettings.tsx |
笔刷参数面板 | 146 | — |
components/ColorPanel/ColorPanel.tsx |
颜色选择 | 131 | — |
components/SegmentsPanel/SegmentsPanel.tsx |
分区管理 | 105 | — |
components/StatusBar/StatusBar.tsx |
状态栏 + DebugHud | 91 | — |
types/mesh.ts |
TS 类型定义 | 83 | MeshData, Segment, PaintResult, PaintTool, AMS_PALETTE |
i18n.ts |
中英文案 | 100 | — |
theme.css |
主题 CSS 变量 | 128 | — |
utils/logger.ts |
日志 | 53 | — |
App.tsx / main.tsx |
外壳 / 入口 | 84 / 10 | — |
Viewport.tsx 已达 2141 行,是明确的重构候选(相机控制、拾取、套索覆盖层、高亮覆盖层、HUD 探针混杂于单文件)。
三、前后端通信契约(18 个 Tauri 命令)
多数 DTO 采用 #[serde(rename_all = "camelCase")](Segment — model.rs:14、MeshDataDto — model.rs:99),前端 key 为小驼峰。例外:BoundingBox(model.rs:7)未加该属性,因其字段名 min/max 为单词,恰好无对齐问题。二进制数组直接映射 Uint8Array / Uint32Array,无额外转码层。
下表入参列已省略 Tauri 自动注入的 state: State<AppState> 与 app: AppHandle,只列前端必须传递的参数(名称为 Rust 侧蛇形名,前端发送时用小驼峰)。
| 命令 | 定义 | 前端调用点 | 入参 | 返回 | 用途 |
|---|---|---|---|---|---|
load_model |
mesh.rs:22 | useTauriCommand:18 |
path: String |
MeshDataDto |
STL → 网格 DTO |
get_face_color |
mesh.rs:74 | 前端无调用 | face_id: u32 |
[u8;4] |
死接口,建议移除或接入吸管 |
brush_paint |
paint.rs:21 | usePaintTool:155 |
center_face, radius, strength, falloff_mode, color |
PaintResult |
笔刷 |
fill_paint |
paint.rs:68 | usePaintTool:146 |
face_id, color, radius(radius 0 → flood) |
PaintResult |
填充 / 半径填充 |
fill_segment_paint |
paint.rs:116 | usePaintTool:46 |
segment_id, color |
PaintResult |
整段填充 |
spray_paint |
paint.rs:134 | usePaintTool:165 |
center_face, radius, strength, color, density |
PaintResult |
喷枪 |
smart_brush_paint |
paint.rs:155 | usePaintTool:175 |
与 brush_paint 完全同参 |
PaintResult |
智能笔 |
erase_paint |
paint.rs:176 | usePaintTool:185 |
center_face, radius |
PaintResult |
橡皮 |
pick_color |
paint.rs:204 | usePaintTool:192 |
face_id: u32 |
[u8;4] |
吸管 |
auto_segment |
segment.rs:33 | useTauriCommand:45 |
angle_threshold: f32 |
SegmentResult |
二面角分割 |
auto_segment_smart |
segment.rs:317 | useTauriCommand:149 |
k: u32 |
SegmentResult |
SDF 分割 |
paint_segment_face |
segment.rs:89 | useTauriCommand:243 |
face_id: u32, segment_label: Option<u32> |
PaintSegmentFaceResult |
分区笔刷面 |
finalize_segment |
segment.rs:144 | useTauriCommand:263 |
segment_label: u32 |
FinalizeSegmentResult |
重建分区元数据 |
manual_region_add_point |
segment.rs:187 | useTauriCommand:73 |
point: [f32;3], face_index: u32 |
ManualPointResult |
套索加点 |
finalize_manual_region |
segment.rs:214 | useTauriCommand:93 |
points: Vec<[f32;3]>, face_indices: Vec<u32> |
SegmentResult |
套索闭合 |
manual_region_undo |
segment.rs:244 | useTauriCommand:127 |
无参 | SegmentResult |
撤销手动区(LIFO) |
restore_face_colors |
segment.rs:271 | useTauriCommand:189 |
face_colors: Vec<u8>, segment_labels: Vec<u32>(两者长度均被校验) |
SegmentResult |
撤销/重做重绘 |
export_3mf_command |
export.rs:8 | useTauriCommand:167 |
path: String |
String(成功消息) |
导出 3MF |
smart_brush_paint的法线阈值不是入参。它是编译期常量SMART_NORMAL_MIN_DOT = 0.5(smart_snap.rs:9),前端无法调节。按"可传阈值"实现会导致 Tauri 参数反序列化失败。
export_3mf_command返回Result<String, String>,成功时为format!("Exported to {}", ...)。易与内层export::threemf::export_3mf(threemf.rs:12,返回Result<(), String>)混淆。当前前端忽略该返回值,问题被掩盖但契约本身如此。
lib.rs:22-41 的 invoke_handler! 注册 18 项,与 18 个 #[tauri::command] 宏一一对应,无未注册命令、无幽灵注册。
关键返回结构
PaintResult { updated_faces: Vec<u32>, updated_colors: Vec<[u8; 4]> }
SegmentResult { segments, segment_labels, face_colors: Vec<u8> }
前端据 PaintResult 做增量 GPU 写入,不重建几何体。SegmentResult.face_colors 常被忽略,但它是"重新分区不丢失已有涂色"的关键字段(segment.rs:24-28 / types/mesh.ts:43)。
commands/segment.rs 另定义三个返回结构:PaintSegmentFaceResult(:76)、FinalizeSegmentResult(:135)、ManualPointResult(:176)。
四、核心数据结构
Rust(mesh/model.rs)
struct MeshModel { // model.rs:77
vertices, faces, normals,
face_colors, // Vec<[u8;4]>,默认全 DEFAULT_FACE_COLOR
segment_labels, // Vec<u32>,长度必须 == faces.len()
segments, // HashMap<u32, Segment>,键 = segment label
manual_region_history, // Vec<ManualRegionSnapshot>,支撑 LIFO 撤销
face_kdtree, vertex_kdtree, face_adjacency,
bbox, unit, // unit 恒为 "millimeter",全库无读取点(死字段)
}
struct MeshDataDto { // model.rs:99,跨 IPC 的 DTO
segments, // Vec<Segment> ← 注意与 MeshModel 不同
...
}
struct Segment { id, name, color, face_count } // serde → camelCase
segments在两层的类型不同,这是刻意设计而非笔误。 内存模型MeshModel.segments是HashMap<u32, Segment>(按 label 键索引),跨 IPC 的MeshDataDto.segments是Vec<Segment>(序列化为 JSON 数组)。转换点统一写作mesh.segments.values().cloned().collect()(segment.rs:158 / 233 / 256 / 308)。把内存层当Vec使用会直接编译失败。
全局常量(定义位置分散,不集中于 model.rs)
| 常量 | 值 | 定义位置 | 含义与依据 |
|---|---|---|---|
DEFAULT_FACE_COLOR |
[138,138,138,255] |
model.rs:68 | 中灰。纯白在浅色画布对比度仅 1.06:1(对比度表见 model.rs:44-58) |
MANUAL_SEGMENT_OFFSET |
100_000 |
model.rs:37 且 usePaintTool.ts:21 | 手动分区标签起点。前后端各有一份定义,修改须原子同步 |
SMART_NORMAL_MIN_DOT |
0.5 |
smart_snap.rs:9 | 智能笔法线裁剪,防球查询穿透薄壁 |
SDF_RAYS / SDF_CONE_HALF_ANGLE |
12 / 1.05 |
sdf.rs:23-24 | SDF 采样射线数与锥半角 |
MERGE_NORMAL_DOT_THRESHOLD |
0.93 |
dihedral.rs:44 | 法线一致性合并阈值 |
MIN_REGION_CAP |
30 |
dihedral.rs:37 | 小分区吸收上限(对 150 万面模型过激,见遗留问题) |
SMOOTH_PASSES |
1 |
manual.rs:307 | 形态学平滑遍数 |
WHOLE_SEGMENT_MAX_SHARE |
0.8 |
usePaintTool.ts:15 | 前端:超此占比不按整段填充 |
MANUAL_REGION_MAX_SHARE |
0.95 |
usePaintTool.ts:16 | 前端:手动区上限(取值无注释依据) |
maxZoom / minZoom |
500 / 0.1 |
Viewport.tsx:305 / 304 | 正交相机缩放范围 |
吸附参数是前后端各自独立的字面量,无编译期约束——这是一处真实风险:
| 参数 | 前端 | 后端 | 值 |
|---|---|---|---|
| 吸附半径系数 | SNAP_K(Viewport.tsx:658,具名常量) |
let k = 1.5_f32(manual.rs:80,函数内局部字面量) |
1.5 |
| 同侧余弦阈值 | SNAP_COS_THRESH(Viewport.tsx:696,具名常量) |
(60.0f32).to_radians().cos()(manual.rs:92,局部字面量) |
cos 60° = 0.5 |
后端两处未提升为具名常量,无法通过 grep 保证与前端同步。
阈值角度的正确读法:实际取 60°,不是 54.74°。
arccos(1/√3) ≈ 54.7356°是立方体角点处顶点法线与面法线的夹角,构成理论下界;代码取 60° 是为该下界留约 5° 余量(论证原文见 manual.rs:87-90)。低于 54.74° 会与相邻面法向混淆。
TypeScript(types/mesh.ts)
MeshData 镜像 MeshDataDto;PaintTool 枚举含 View / Fill / Brush / Spray / SmartBrush / Eyedropper / Eraser / Segment / Lasso;PaintSnapshot { faceColors: Uint8Array, segmentLabels: Uint32Array };AMS_PALETTE 为标准料盘色板。
五、状态管理(zustand + persist)
完整 state:meshData, isLoaded, activeTool, brushRadius, brushStrength, brushFalloff, shadingMode, theme, currentColor, segments, selectedSegment, hoveredSegment, snapEnabled, segmentView, toast, undoStack, redoStack, language, statusMessage, isLoading, importProgress, importStage, lastPaintDebug, hoverProbe
持久化白名单(partialize,共 8 键)
theme · language · shadingMode · brushRadius · brushStrength · brushFalloff · currentColor · snapEnabled
存储键 PREFS_KEY = "cym-prefs",兼容旧键 LEGACY_THEME_KEY = "cym-theme"。
mergePrefs 校验规则
| 字段 | 规则 |
|---|---|
theme |
∈ {dark, light} |
language |
∈ {zh, en} |
shadingMode |
∈ {flat, shaded} |
brushFalloff |
∈ {linear, smooth, step} |
brushRadius |
clamp(0.5, 200) |
brushStrength |
clamp(0.1, 1.0) |
snapEnabled |
布尔 |
currentColor |
长度 4 且全有限 → round + clamp(0,255);否则回退默认 |
pickStorage 探测 localStorage 可用性,失败回退 memoryStorage。
关键模式:applyFaceColors 原地 mutate,不调 set()
更换 meshData 引用会触发 paintColorArray 从陈旧色重算并全量覆盖 GPU,还会重置相机。此模式已成为项目通用约定。
关键约定:updateSegmentLabels 必须双写
同时写顶层 segments 与 meshData.segments。仅写顶层会使 Fill 路由与分区悬停高亮双双静默失效。
六、关键算法
| 算法 | 位置 | 核心思路 |
|---|---|---|
| SDF 智能分割 | segment/sdf.rs |
法线一致化(BFS) → 内向锥形射线取中值厚度 → 对数归一化 → 一维 k-means(estimate_k 直方图峰值,clamp 2–12) → 凹度合并(dot ≥ 0.93) |
| 二面角分割 | segment/dihedral.rs |
共享边二面角 >阈值处断裂 → 法线一致性迭代合并 → petgraph 小分区吸收 |
| 套索区域切分 | segment/manual.rs |
顶点图 Dijkstra 加密环边(front_faces 按法线过滤,防穿隧背面)→ 面图 BFS 跳过环边 → 取较小连通分量 |
| 顶点吸附 | manual.rs + Viewport.tsx |
命中面 1-ring 候选 → 法线同侧过滤(cos 60°) → 局部尺度半径(k×maxEdge) → 三级回退。前后端必须原子同步,否则预览与提交漂移 |
| 边界平滑 | manual.rs |
形态学 ERODE(out > 2*in) + DILATE(in > 2*out),带 in_c > 0 永不删区守卫 |
| 笔刷填充 | paint/brush.rs paint/fill.rs |
KD-Tree 半径命中 + falloff_strength;fill_paint 依 radius 分流 flood / 半径盘 |
| 智能笔 | paint/smart_snap.rs |
同分区约束 + 法线点积 ≥0.5 裁剪(廉价替代测地 BFS,O(k)) |
| 喷枪 | paint/spray.rs |
LCG PRNG 按密度采样 + 二次衰减 + jitter |
七、渲染与交互层(Viewport.tsx)
相机
- 正交投影,经
Canvas orthographicprop 声明(R3F 在 resize 时自动按size重算 frustum;drei<OrthographicCamera>需自行管理 frustum 易漏)。 [50,50,50]只是<Canvas camera={{position, zoom:1}}>的初始 prop(Viewport.tsx:2102),CameraFit并不使用它。CameraFit按包围盒动态求解:方向固定(0.4,0.4,1).normalize(),距离viewDist = maxDim × 10,位置center + dir × viewDist(Viewport.tsx:180-182);zoom = min(fit × 0.88, 500)(:193)。相机不是固定在 iso 角落。ControlsBridge:minZoom=0.1、maxZoom=500、enableDamping=false(惯性积分会在右键平移期间继续施加旋转)。
鼠标映射(applyCameraButtons)
| 上下文 | LEFT | MIDDLE | RIGHT | Alt+LEFT |
|---|---|---|---|---|
| 视图模式 | ROTATE | DOLLY | PAN | ROTATE |
| 绘制/套索 + 光标在模型上 | MOUSE_NONE (-1) |
DOLLY | PAN | ROTATE |
| 绘制/套索 + 光标在空白 | ROTATE | DOLLY | PAN | ROTATE |
overModelRef 为模块级共享 ref,驱动上述上下文切换。约定:任何新工具必须在自身 pointermove 的命中/未命中两个分支都刷新它,否则读到冻结旧值。
拾取
- 射线法 +
three-mesh-bvh,raycaster.firstHitOnly = true。 - BVH 构建必须
computeBoundsTree({ indirect: true })—— 默认模式会物理重排geometry.index,破坏 faceIndex 与后端面索引的对应关系。 - 构建移入
setTimeout(0)effect,未建树期自动回退暴力射线。 - 几何体经
toNonIndexed(),保证faceIdx*3+v的逐顶点写色寻址成立。
高亮覆盖层
SegmentOutline(lineSegments 描边)、SegmentHighlight(meshBasicMaterial, opacity 0.35)、LassoOverlay;OVERLAY_COLORS 提供 dark/light 双套(满足 WCAG 对比度)。
已知副作用:所有 overlay 均 depthTest = false,恒绘制于最前,不被模型遮挡;面数 >50k 时跳过 wireframe(20 万面同步构造线框会阻塞 1.5–4 秒)。
诊断
Tauri release 无 devtools,故诊断探针全部打到 UI:DebugHud 输出 face= / seg= / hover= / color= / gpu= / shade= / N filled / M highlighted ⚠️ MISMATCH,以及 [HOVER] [CLICK] [FILL-RX] 三级探针。
八、构建与验证
npx tsc --noEmit # 前端类型检查,须 exit 0
cd src-tauri && cargo test --lib # 后端 13 项单测
npx tauri build --bundles nsis # 打包(须先 unset NODE_OPTIONS)
产物:ColorYourModel_x64-setup.exe(~3.1 MB)、color-your-model.exe(~14 MB)
已知死代码:MeshModel.unit 字段(model.rs:94 声明,:128 赋值 "millimeter",全库无读取点)。
早期版本此处记为"2 处
dead_code警告,含apply_falloff",该断言已被证伪:apply_falloff在代码库中不存在(0 处命中);真实存在的是falloff_strength(brush.rs:29),且被commands/paint.rs:8,55与paint/smart_snap.rs:4,57三处调用,属热路径函数。具体警告条数须以cargo build输出为准,本文不再断言计数。
打包环境注意
- 必须
unset NODE_OPTIONS(沙箱 safe-delete shim 会破坏 vite 清 dist) - 必须
--bundles nsis显式限定,bundle.targets="all"会尝试需联网的 WiX - choco 安装 NSIS 后
makensis不入 PATH,需手动软链