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 --lib13 项、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 点击后输出纯黑多边形。
- 错误假设链:
- overlay 压暗(iter25)——
setHoveredSegment(null)只清一帧,下次 pointermove 立即恢复; - 300ms 冷却期(iter26)—— 后被证为不可达死代码(ref 赋值不调度渲染,守卫永不求值),污染归因 2 轮;
currentColor默认黑 —— 与源码矛盾,默认是[255,0,0,255];- Lambert + ACES 压暗。
- overlay 压暗(iter25)——
- 决定性论证(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
已知局限
- headless Chromium 无 Tauri runtime —— 凡依赖模型加载(走 IPC)的功能无法自动化验证,只能人工
cargo tauri dev回归。纯 CSS/UI 层可 E2E。 - Tauri release 无 devtools(F12 失效) —— 倒逼 HUD 探针方案。
- 静态读码不能判定可达性与手感 —— C1 的直接教训。
- 前端零单测 —— 全部依赖 tsc + 人工。后端从 3 例起步逐轮补至 13 例。
- 无 CI —— 所有验证靠人工触发。
六、可复用的方法论
以下是从 30 轮迭代中提炼、可迁移到其他项目的规则:
- 取证优先于修改。 无判据的假设不要动手,更不要一次改三个假设 —— 改完无法归因。(Fill saga 六轮弯路的唯一根源)
- 同一思路失败 2 次就换角度。 不做补丁式修复。(C4 的 v1–v6 违反了这条,代价是六个版本)
- 诊断探针不能有二义性。
seg=0分不清 null 和 0,会自己制造弯路。 - 先确认输入数据在调用点是否真的非零,再去假设闭包、竞态、作用域。
- 修复必须落到"共享入口"而非分支。 射线拾取替换 GPU 拾取之所以有效,是因为它是全工具唯一入口。
- 验证清单用黑名单而非白名单。 白名单漏掉即静默通过;黑名单归零才是证明。
- "看起来像 A"可能是 panic 后的次生现象。 先排除崩溃,再谈业务逻辑。
- 数学定性能一击终结猜测链。 Lambert 乘性论证一步排除了四个色彩假设中的三个。
- REFUTE 的价值是省下实现成本。 上表 16 条被拦截方案中,至少 6 条若实现将引入回归。
- 用户反馈可以推翻整条推理链。 iter29 用户一句"黑色是我故意选的"直接作废了五轮工作 —— 应更早向用户确认前提。
七、流程本身的问题
诚实记录,供后续改进:
- 迭代 14/15/19/24/25 无 journal 专章 —— 记录纪律在高压修复期会断档,而恰恰这些迭代含关键根因。
- iter13 标注"进行中"后被推翻,结论未回填 —— 被推翻的迭代也应写明"为何被推翻"。
- 迭代 1–21 长期未提交 git —— 直到 08-04 才补交,期间无法二分定位回归。
- Fill saga 耗费 6 轮仍未收敛 —— 若在 iter25 就要求"先出判据再改",预计可压缩到 2 轮。