ColorYourModel logoColorYourModel Docs
GitHub ↗

ColorYourModel 迭代复盘与工程经验沉淀

覆盖范围 30 轮对抗式开发迭代(2026-07-24 ~ 2026-08-05)+ 32 次 git 提交
开发流程 Adversarial Development Loop:PLAN → REFUTE(独立子代理证伪)→ REVISE → IMPLEMENT → TEST → RETROSPECT
文档日期 2026-08-06

本文档的价值不在于"做了什么",而在于"错在哪里、为什么错、以及如何不再错"。 所有根因均来自 loop-journal.md(1139 行)与 10 份日报的原始记录,未作推测。

核对状态:2026-08-06 经独立 agent 抽查文中引用的具体标识符与命令 —— meshData.segments 双写、maxZoom 50→500、#8a8a8a、cargo test --lib 13 项、npx tsc --noEmit、npx tauri build --bundles nsis —— 未发现与源码漂移。迭代过程叙事本身以 journal 为唯一出处,不另做源码核验。


一、迭代阶段总览

阶段 迭代号 主题 核心改动 结果
S0 奠基 loop 前 SDF 分割 + 套索地基 segment_by_sdf;region_from_loop 重写为边屏障 BFS;单测 3→8 成功
S1 首次翻车 1–2 相机控制 / 选区无反馈 iter1 补 rebuild_segments()、拆几何 iter1 被推翻(修的是下游症状),iter2 换策略成功
S2 拾取统一 3–5 相机映射 + Undo/Redo + 拾取重构 GPU 拾取 → 射线拾取;全局 Ctrl+Z/Y 成功。iter5 纯审计,REFUTE 未能证伪,零改动
S3 相机视差长征 6–10 平移带旋转 / 吸附不一致 target 修正 → screenSpacePanning → 关阻尼 → tilt 三级压低 → 硬切正交投影 部分→成功。iter8 A/B 方案全被推翻,iter10 才根治
S4 面序错位 + 性能 11–16 "涂 A 显 B" / 卡顿 iter14 真根因:索引几何 + 逐顶点色;串行 enqueuePaint;BVH + 线框门槛 iter13 方向被 iter14 推翻;14/15/16 成功
S5 填充语义 17–19 Fill 反馈 / 范围 / 材质 描边式高亮;segment_labels 初始化(崩溃根因);材质可切换 成功。iter18 的 meshBasicMaterial 当日热修回退
S6 主题与持久化 20–21 双主题 + 偏好持久化 CSS 变量 + FOUC 内联脚本;zustand persist 8 键白名单 成功。唯一有截图证据的阶段
S7 数据流根因 22–24 套索旋转 / 无限放大 / Fill 失效 meshData.segments 双写;overModelRef 三处刷新;maxZoom 50→500 成功。iter22 初版三条全被推翻
S8 Fill 长尾 25–30 Fill "变黑" → 实为范围溢出 7 版修复(overlay→冷却→删死码→数学定性→用户纠偏→六连修→stickiness) 迄今未验证收敛

注:迭代 14 / 15 / 19 / 24 / 25 在 journal 中无专章,内容仅存于日报;迭代 1–21 多数条目当时未提交 git,直到 08-04 才迁移到 feat_theme_persist_paint_ux 分支分 5 个 commit 补交。


二、关键技术决策

决策 备选方案 选择依据 后续
正交相机硬切换 ①透视+压低 tilt ②平移模型组 ③锁投影 ④透视/正交 Toggle 透视下不同深度物体屏幕位移不同 = 视差,数学上无法满足"绝对 Pan"。Toggle 被否:双相机交互中途跳变,且透视态仍带被否定的视差 未被推翻,成为基石
Canvas orthographic prop drei <OrthographicCamera> R3F 在 resize 时自动按 size 重算 frustum;drei 方案需自管 frustum 易漏 未被推翻
射线拾取取代 GPU 像素拾取 修 GPU RT 的 dpr 换算 rect ≠ size 时读回像素整体错位;射线 NDC 由 rect 归一化,DPR/分辨率无关。属"换策略而非打补丁" 未被推翻。iter5 审计确认这是全工具共享入口的系统性修复
three-mesh-bvh + indirect: true 默认 computeBoundsTree() 默认模式会物理重排 geometry.index → 复活 iter14 的"涂 A 显 B"。且原为 drei 幻影传递依赖 → 显式声明 未被推翻
非索引几何 + 逐顶点色 保留索引几何改写色方式 faceIdx*3+v 寻址要求每面独占三顶点;toNonIndexed() 保持面顺序,raycast faceIndex 仍等于后端 face index 未被推翻
同侧吸附 + 局部尺度阈值 ①全局最近顶点 ②全局 bbox% ③联合评分 w/distance + w·dot 全局最近在薄壳上会吸到背面;bbox% 不适应局部密度;联合评分有除零 UB(闭环 distance=0)且尺度依赖 演进三次,最终 60° + k×maxEdge 三级回退
zustand persist 中间件 继续手写 loadTheme/saveTheme 仅 theme 持久化破坏对称性。选 curried API + 白名单 + 值域校验 + 存储探测 + 内存兜底 未被推翻
原地 mutate 不调 set() 正常 set() 更新 换 meshData 引用会触发从陈旧色重算并全量覆盖 GPU,还会重置相机 成为通用模式
CSS 变量 + 黑名单验证 白名单逐组件替换 hex 白名单漏掉即静默失败;自估"约 45 处"实际 65 处 改用 grep 强制归零
3D 画布跟随主题 保持深色中性 iter20 判定"白模在浅底不可见"→ iter21 用户要求浅色并授权改默认面色为 #8a8a8a 决策被显式推翻并留痕(标注 SUPERSEDED)

三、踩坑与根因清单

这一节是本文档的核心。每条的结构是:现象 → 错误假设 → 真实根因 → 教训。

C1. 闭环判定不可达(iter1→2)

  • 现象:套索点完消失,毫无反馈。
  • 错误假设:finalize_manual_region 漏调 rebuild_segments()。iter1 照此修了、进包了、无效。
  • 真实根因:闭环用精确顶点匹配(res.vertexIndex === startVertexRef),稠密网格下最近顶点的 Voronoi 单元极小,几乎不可能命中;而视觉黄点用的是距离阈值。两套判据不一致 → finalize 从未被调用。iter1 修的是下游症状。
  • 教训:涉及"可达性 / 手感"的判定不能仅靠静态读码。 上一轮 REFUTE 子代理正是静态读码断言"可达"而误判。此类判定需用阈值量级论证或真机验证。

C2. 索引几何 × 逐顶点色 = 涂 A 显 B(iter13→14)

  • 现象:画笔环在右手,红色涂在左上臂,偏移达模型尺寸的 30–40%。
  • 错误假设:iter13 判定"前后端物理面索引不一致",投入 4 个 DIAG 检查点。且 F12 在 Tauri release 无 devtools → 诊断日志全部作废。
  • 真实根因:几何是索引几何(共享顶点),颜色却按 faceIdx*3+v 写入 —— 该寻址方式要求非索引布局。静态分析反而证明 faceIndex 顺序本来就是一致的。
  • 教训:桌面端 release 无 devtools,诊断探针必须打到 UI 层。 后续 iter29/30 全部依赖 DebugHud。

C3. Fill "变黑" → 实为范围溢出(iter25–30,六轮修错方向)

  • 现象:Fill 点击后输出纯黑多边形。
  • 错误假设链:
    1. overlay 压暗(iter25)—— setHoveredSegment(null) 只清一帧,下次 pointermove 立即恢复;
    2. 300ms 冷却期(iter26)—— 后被证为不可达死代码(ref 赋值不调度渲染,守卫永不求值),污染归因 2 轮;
    3. currentColor 默认黑 —— 与源码矛盾,默认是 [255,0,0,255];
    4. Lambert + ACES 压暗。
  • 决定性论证(iter28):Lambert 是乘性的 —— [0,0,0] 在任何光照下精确纯黑,非零红最坏也是 rgb(88,0,0) 暗红。输出纯黑 ⟹ 缓冲区本身就是 0。
  • 用户纠偏(iter29):黑色是故意选的 AMS Black,颜色本身没问题。真 bug 是填充面片范围 > 黄色高亮范围。
  • 决定性证据:fill face=517527 seg=0 → 12 filled / 1498252 highlighted ⚠️ MISMATCH。seg=0 是覆盖 150 万面的默认分区。
  • 元教训:取证优先于修改。 iter28 的 REFUTE 明确指出"三个假设无判据却打算一起改,违反先取证再改"。六轮弯路的根源都在于此。

C4. hoveredSegment 七版修复(iter29 v1–v6 + iter30 v7)

同一个症状 seg=0(click),提出并否决了七个不同根因:

版本 假设 为何无效
v1 读 store 的 hoveredSegment 它是 Viewport 本地 useState,根本不在 zustand,getState() 永远 undefined
v2 改为参数传递 值传到了,但传的是 null
v3 useCallback 缺依赖导致 stale closure 补了依赖仍是 null
v4 竞态:async IIFE 不阻塞,pointerup 同步清 hover 快照仍 null —— 不是竞态
v5 lastValidHoveredSegmentRef 回退 ref 也被提前清掉
v6 粘性:默认值从 null 改为 prevHover 只防 null 清除,不防合法覆写
v7 真根因:手动区画完后 seg=0 只剩约 13 面碎片 → 占比≈0 → 被移出巨型分区集合 → pointermove 命中碎片时所有守卫通过 → 合法覆写手动区 ID 待验证
  • 教训一:先确认"输入数据在调用点是否真的非零",再去假设闭包、竞态、作用域。 v1–v5 全是在数据源本身为 null 的前提下修传递链路。
  • 教训二:诊断输出必须无歧义。seg=0(click) 无法区分 "hover=null" 与 "hover=0",直到 iter30 补了 hover= 字段才消歧。二义的探针会自己制造弯路。
  • 教训三:清除状态时若破坏下游消费者,"保留上一次有效值"比"额外加 ref 回退"可靠。

C5. meshData.segments 从不写入(iter22)

  • 现象:Fill 一律表现为涂抹圆斑,识别不了分区。
  • 真实根因:updateSegmentLabels 只写顶层 segments 切片,从不写 meshData.segments → 该数组整个会话恒为 [] → usePaintTool 里 seg 恒 undefined → Fill 全量降级。同源受害者:分区悬停高亮一并失效。
  • 教训(已升格为项目长期约定):分区元数据必须双写,不能只写顶层切片。

C6. overModelRef 冻结值(iter15 建立、iter22 复发)

  • 现象:套索模式下修改 mouseButtons.LEFT 完全无效。
  • 真实根因:套索模式下 onPointerMove 有提前 return + !isLassoTool 守卫双重排除,onPointerLeave 也不复位 → ref 恒为冻结旧值。
  • 教训(已升格为长期约定):任何新工具必须在自身 pointermove 的命中/未命中两个分支都刷新 overModelRef。

C7. segment_labels 未初始化(iter18)

  • 现象:未分区模型点分区笔"点一下就异常",看似相机重置。
  • 真实根因:Vec::new() 空向量 → segment_labels[face_id] 越界 panic 并毒化 Mutex。与相机毫无关系。
  • 教训:"看起来像 A 的症状"可能只是 panic 后的次生现象。 先确认有没有 panic,再分析业务逻辑。

C8. TDZ 黑屏(iter24)

  • 现象:加载模型后完全黑屏。
  • 真实根因:giantSegmentIds 的 useMemo 工厂引用了声明在其后的 emptySet(L1034 vs L1018)。React 按声明顺序同步执行工厂 → 首次渲染时 emptySet 处于暂时性死区 → ReferenceError → 整个组件树崩溃。
  • 教训:useMemo 工厂之间的引用关系同样受 TDZ 约束。

C9. 其他高价值坑

坑 表象 真实根因 解法
kd-tree 球查询穿透薄壁(iter18) 智能笔"越界" 3D 球查询命中背面,且背面同属一个 segment,段过滤抓不到 法线点积裁剪 cos60°,O(k),无需测地 BFS
Dijkstra 穿隧背面(iter9) 分区"水波外溢" 不是 barrier 漏洞(邻接图只连共享边,水密),是加密路径绕到背面 front_faces 按法线过滤
async 乱序写色(iter15) 重叠区颜色被覆盖 多个 invoke fire-and-forget 乱序返回 串行 enqueuePaint,iter16 改 latest-wins 节流
enableDamping 惯性积分(iter7) 右键平移时"多余旋转" 左键旋转释放后惯性每帧继续施加 enableDamping = false
单测断言缺陷冒充算法缺陷(07-24) 算法"算错了" unit_cube 以 z 为 up,测试却断言 c[1]≈1(y) 修测试,非修算法
wireframe 是导入卡顿真凶(iter16) 导入卡 1.5–4 秒 20 万面同步构造 360 万元素数组 + 720 万次字符串拼接 >50k 面跳过线框
meshBasicMaterial 白色剪影(iter18) 模型变纯白扁平 完全不响应光照,丢失形体可读性 退回 Lambert,iter19 做成可切换

四、REFUTE 环节拦截的错误设计

这是对抗式流程价值最直接的证据 —— 以下方案在动手前就被证伪:

迭代 原定错误方案 拦截理由
8 快照 spherical 每帧恢复 / 右键时关 enableRotate 机制性无效:pan 不写 sphericalDelta;spherical 每帧从 position−target 重派生,快照立即被覆盖;PAN 路径只查 enablePan
9 分区外溢改用"质心种子面" 邻接图只连共享边,barrier 本就水密;真因是 Dijkstra 穿隧背面
10 透视/正交 Toggle 双相机会在交互中途投影跳变;透视态仍带被否定的视差;且属扩需求
11 拖动分支加第二次 raycast 会重燃 O(F) 性能热点 → 改抽 raycastFace() 共享单次 raycast
12 全局 bbox% 吸附阈值 不适应局部密度 → 改局部尺度 k×maxEdge
16 默认 computeBoundsTree() 会物理重排 index → 复活 iter14 缺陷
16 rAF 合并 pointermove WebView2 下 pointermove 已 rAF 对齐,合并近似 no-op,且会把"滞后"换成"断点"
17 实心三角面片高亮 谎报 fill 范围(fill 实际泛洪整个分区)→ 改画 outline
18 flood_fill + filter(label) O(F) 全段 BFS,会复活 iter16 卡顿 → 改 kd-tree 半径盘
20 白名单逐 hex 替换(自估 45 处) 清单是自我一致的假象(44 处同源于同一份残缺枚举),实际 65 处,漏掉真实画布背景与 5 个悬浮子组件
22 仅改 mouseButtons.LEFT / 仅改 maxZoom / Fill 手动分区特例 三条全推翻:ref 冻结、CameraFit 未 clamp 会被反压、Fill 是全量失效非特例
23 hover 守卫 share>0.8 && count≤1 / 合并 4 次 set / 启用 snapEnabled 条件 AND 写反在 27 分区下永不触发;React 18 已自动批处理;snapEnabled 无 UI 开关且默认 false,启用会让吸附永久关闭无法恢复
26 黑色 clamp [0,0,0]→[32,32,32] / 联合评分吸附 破坏 AMS 合法黑色,且 ColorPanel 选中态永远 false;联合评分 w/distance 有除零 UB
27 三条假设(几何覆盖 / 默认黑 / 格式不匹配) 全部不可达;并指出 iter26 装的 HUD 探针方案里根本没提要读它
30 setPointerCapture 触发 pointerleave 同目标不派发边界事件,同步栈无让出点,strict no-op

五、验证体系与局限

验证手段(红线:改完必须贴证据,不能只说"应该没问题")

手段 说明
npx tsc --noEmit 每轮必跑,全程 exit 0
cargo test --lib 3 → 8 → 9 → 10 → 11 → 12 → 13,稳定至 iter30
npx tauri build --bundles nsis 产物核验到字节级(setup ~3.1MB / 便携 ~14MB)
grep 落地确认 iter22 确认 5 处、iter23 确认 8 处改动到 file:line
黑名单 grep 主题化用正则强制归零,取代易漏的白名单
HUD 探针 DebugHud 输出 face/seg/hover/color/gpu/shade + N filled / M highlighted
headless Chromium 仅 iter21 用过,产出主题切换截图

关键回归测试:finalize_populates_segment_metadata、manual_region_on_open_mesh、restore_paint_state_roundtrip、undo_restores_previous_state、smooth_preserves_solid_block_and_never_deletes

已知局限

  1. headless Chromium 无 Tauri runtime —— 凡依赖模型加载(走 IPC)的功能无法自动化验证,只能人工 cargo tauri dev 回归。纯 CSS/UI 层可 E2E。
  2. Tauri release 无 devtools(F12 失效) —— 倒逼 HUD 探针方案。
  3. 静态读码不能判定可达性与手感 —— C1 的直接教训。
  4. 前端零单测 —— 全部依赖 tsc + 人工。后端从 3 例起步逐轮补至 13 例。
  5. 无 CI —— 所有验证靠人工触发。

六、可复用的方法论

以下是从 30 轮迭代中提炼、可迁移到其他项目的规则:

  1. 取证优先于修改。 无判据的假设不要动手,更不要一次改三个假设 —— 改完无法归因。(Fill saga 六轮弯路的唯一根源)
  2. 同一思路失败 2 次就换角度。 不做补丁式修复。(C4 的 v1–v6 违反了这条,代价是六个版本)
  3. 诊断探针不能有二义性。 seg=0 分不清 null 和 0,会自己制造弯路。
  4. 先确认输入数据在调用点是否真的非零,再去假设闭包、竞态、作用域。
  5. 修复必须落到"共享入口"而非分支。 射线拾取替换 GPU 拾取之所以有效,是因为它是全工具唯一入口。
  6. 验证清单用黑名单而非白名单。 白名单漏掉即静默通过;黑名单归零才是证明。
  7. "看起来像 A"可能是 panic 后的次生现象。 先排除崩溃,再谈业务逻辑。
  8. 数学定性能一击终结猜测链。 Lambert 乘性论证一步排除了四个色彩假设中的三个。
  9. REFUTE 的价值是省下实现成本。 上表 16 条被拦截方案中,至少 6 条若实现将引入回归。
  10. 用户反馈可以推翻整条推理链。 iter29 用户一句"黑色是我故意选的"直接作废了五轮工作 —— 应更早向用户确认前提。

七、流程本身的问题

诚实记录,供后续改进:

  1. 迭代 14/15/19/24/25 无 journal 专章 —— 记录纪律在高压修复期会断档,而恰恰这些迭代含关键根因。
  2. iter13 标注"进行中"后被推翻,结论未回填 —— 被推翻的迭代也应写明"为何被推翻"。
  3. 迭代 1–21 长期未提交 git —— 直到 08-04 才补交,期间无法二分定位回归。
  4. Fill saga 耗费 6 轮仍未收敛 —— 若在 iter25 就要求"先出判据再改",预计可压缩到 2 轮。
On this page