ColorYourModel logoColorYourModel Docs
GitHub ↗

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 orthographic prop 声明(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,需手动软链
On this page