Appearance
ADR-126: 变换适配器统一(TransformAdapter Registry)— 跨 kind 拖拽/数值双模态去重
状态: ✅ 已完成(Phase 1 + Phase 2 + Phase 3 全量落地;2026-07-18 代码审核通过:P1 修复 + P4 派生单测补齐) 日期: 2026-07-18 路径约定: 本文档源码路径均省略
frontend/src/前缀(与 ADR-121 / ADR-120 一致),例如scene/render/transform-gizmo.ts=frontend/src/scene/render/transform-gizmo.ts。
背景与问题
「3D 拖拽(Gizmo)」是一项跨 kind 的横向能力(actor / stage / prop / light 都要用),但当前实现按 kind 纵向摊进了三个场景模块,并在唯一调用方 menus/resource-detail-helpers.ts 的 buildTransformCard(:51-223)里以硬编码 if/else 分支收口。这违反 AGENTS.md「显著重复」反模式(相似逻辑出现在 ≥2 文件),也让「新增一个可变换 kind」的成本线性上升。
现状架构
transform-gizmo.ts (核心 singleton:attachGizmo/detachGizmo/isGizmoActive/getGizmoTargetId)
↑
├── model-ops.ts attachModelGizmo() (:193) ← actor/stage
├── props.ts attachPropGizmo() (:365) ← prop
└── lighting.ts attachLightGizmo() (:1031) ← light
↑
三函数结构完全同构,仅「node 来源 / types / 持久化回调」不同
↑
buildTransformCard (:51-223) 唯一调用方
├── Gizmo 拖拽行:actor/stage / prop / light 三分支 (:60-114) ≈54 行
├── 缩放倍率行: actor/stage / prop / light 三分支 (:117-163) ≈47 行
└── 透明度行: actor/stage / prop / light 三分支 (:165-217) ≈53 行已核验的 5 处重复(附文件:行号)
| # | 重复点 | 位置 | 量级 |
|---|---|---|---|
| 1 | 三个 attachXxxGizmo 函数同构(查 registry → 选 node → attachGizmo) | model-ops.ts:193 / props.ts:365 / lighting.ts:1031 | 3 函数 |
| 2 | re-export 风格不一致:别名 vs 显式包装 | model-ops.ts:215-219、props.ts:387-390(别名)↔ lighting.ts:1068-1080(显式包装) | 2 风格 |
| 3 | buildTransformCard Gizmo 三分支同构 | resource-detail-helpers.ts:60-114 | ≈54 行 |
| 4 | buildTransformCard 缩放/透明度再各一轮三分支同构 | resource-detail-helpers.ts:117-217 | ≈100 行 |
| 5 | lighting.ts 灯光类型切换时内联复制 attachLightGizmo 的 direction→target 转换逻辑(未复用函数本体) | lighting.ts(类型切换 re-attach 分支) | 1 处复制粘贴 |
核心症结:buildTransformCard 把「某 kind 支持哪些可编辑变换属性 + 如何读写」硬编码为 3 kind × 3 属性 = 9 段 if/else,共约 150 行同构代码。
关联资产(证明可行性,非从零造)
| 资产 | 对本 ADR 的支撑 |
|---|---|
transform-gizmo.ts 统一 attachGizmo(options) 单例 | 底层已收敛,本 ADR 只需在其上加一层「按 kind 生成 options」的适配器,无需重写 Gizmo 核心 |
ResourceKind 类型 + ResourceHandle(resource-detail-helpers.ts) | 天然作为适配器注册表的键 |
addSliderRow / slideRow(声明式 UI builder,ADR-093) | 数据驱动渲染的现成组件,无需新造 UI |
onDragEndObservable(Babylon Gizmo 原生) | 拖拽结束回写的现成钩子;Phase 2 的实时同步复用 onDragObservable |
决策:TransformAdapter 注册表 + 拖拽/数值双模态
核心思路:把差异点抽象为接口(一个 kind 一次性声明其全部变换能力),把同构逻辑收敛为数据驱动调度器。抽象层级从「Gizmo 适配器」上移到「变换适配器」——因为缩放/透明度与 Gizmo 是同一批按 kind 派发的能力,应一并收口。
1. 能力声明接口
ts
// scene/transform/transform-adapter.ts
import type { Node } from '@babylonjs/core';
import type { GizmoType } from '../render/transform-gizmo';
import type { ResourceKind } from '...';
interface TransformAdapter {
/** 该适配器服务的 kind(actor 与 stage 共用同一适配器 → 数组声明) */
kinds: ResourceKind[];
// ── Gizmo 拖拽(粗调)──
getNode(id: string): Node | null;
gizmoTypes(id: string): GizmoType[];
onPositionDragEnd(id: string, node: Node): void;
onRotationDragEnd?(id: string, node: Node): void;
onScaleDragEnd?(id: string, node: Node): void;
// ── 数值滑杆(精调,能力声明式)──
capabilities: ReadonlyArray<'slider-scale' | 'slider-opacity'>;
getScale?(id: string): number;
setScale?(id: string, v: number): void;
getOpacity?(id: string): number; // 归一化 0..1
setOpacity?(id: string, v: number): void; // 归一化 0..1
}2. 注册表 + 统一调度
ts
const adapters = new Map<ResourceKind, TransformAdapter>();
export function registerTransformAdapter(a: TransformAdapter): void {
for (const k of a.kinds) adapters.set(k, a);
}
export function getTransformAdapter(kind: ResourceKind): TransformAdapter | null {
return adapters.get(kind) ?? null;
}
/** 统一 Gizmo 入口:替代三个 attachXxxGizmo */
export function attachGizmoForKind(kind: ResourceKind, id: string): boolean {
const a = adapters.get(kind);
const node = a?.getNode(id);
if (!a || !node) return false;
return attachGizmo({
id, node, types: a.gizmoTypes(id),
onPositionDragEnd: (n) => a.onPositionDragEnd(id, n),
onRotationDragEnd: a.onRotationDragEnd ? (n) => a.onRotationDragEnd!(id, n) : undefined,
onScaleDragEnd: a.onScaleDragEnd ? (n) => a.onScaleDragEnd!(id, n) : undefined,
});
}detachGizmo / isGizmoActive / getGizmoTargetId 已在 transform-gizmo.ts 存在,直接复用,不再需要 kind 别名。
3. 三个适配器注册(替代三套 attachXxxGizmo + 三套 re-export)
各 kind 模块在文件末尾注册一次,把「查 registry → 选 node → attachGizmo」与「滑杆读写」全部内聚:
ts
// model-ops.ts 末尾(actor + stage 共用)
registerTransformAdapter({
kinds: ['actor', 'stage'],
getNode: (id) => modelRegistry.get(id)?.meshes[0] ?? null,
gizmoTypes: () => ['position', 'scale'],
onPositionDragEnd: (id, n) => { const v = (n as { position: Vector3 }).position; modelManager?.setPosition(id, v.x, v.y, v.z); },
onScaleDragEnd: (id, n) => { const v = (n as { scaling: Vector3 }).scaling; modelManager?.setScaling(id, v.x); },
capabilities: ['slider-scale', 'slider-opacity'],
getScale: (id) => modelRegistry.get(id)?.scaling ?? 1,
setScale: (id, v) => setModelScaling(id, v),
getOpacity: (id) => modelRegistry.get(id)?.opacity ?? 1,
setOpacity: (id, v) => { setModelOpacity(id, v); if (v > 0) setModelVisibility(id, true); },
});
// props.ts 末尾
// 现状约束:resource-detail-helpers.ts:117/166 的缩放/透明度分支已覆盖 actor/stage/prop/light 全部 4 个 kind;
// prop 缩放走 p.scaling → setPropTransform,prop 透明度为布尔可见(visible 布尔,步长 100),
// 适配器将布尔可见映射为归一化 0..1(getOpacity 返回 0 或 1)。
registerTransformAdapter({
kinds: ['prop'],
getNode: (id) => { const p = propRegistry.get(id); return p ? (p.container ?? p.rootMesh) : null; },
gizmoTypes: () => ['position'],
onPositionDragEnd: (id, n) => { const v = (n as { position: Vector3 }).position; setPropTransform(id, { position: [v.x, v.y, v.z] }); },
capabilities: ['slider-scale', 'slider-opacity'],
getScale: (id) => propRegistry.get(id)?.scaling ?? 1,
setScale: (id, v) => { const p = propRegistry.get(id); if (p) { p.scaling = v; setPropTransform(id, { scaling: v }); } },
getOpacity: (id) => propRegistry.get(id)?.visible ? 1 : 0,
setOpacity: (id, v) => { const p = propRegistry.get(id); if (p) { p.visible = v > 0; setPropTransform(id, { visible: v > 0 }); } },
});
// lighting.ts 末尾(direction→target 转换内聚,消除 :655 内联复制)
// 现状约束:resource-detail-helpers.ts:150/204 的缩放/透明度分支已覆盖 light(indicatorScale/indicatorOpacity),
// 适配器直接映射为 getScale/getOpacity 读写。
registerTransformAdapter({
kinds: ['light'],
getNode: (id) => _stageLights.get(id)?.light ?? null,
gizmoTypes: (id) => _stageLights.get(id)?.state.type !== 'point' ? ['position', 'rotation'] : ['position'],
onPositionDragEnd: (id) => { const e = _stageLights.get(id); if (!e) return; const p = e.light.position; setStageLightState({ posX: p.x, posY: p.y, posZ: p.z }, id); },
onRotationDragEnd: (id) => { /* SpotLight/DirectionalLight: target = pos + dir.scale(10) */ },
capabilities: ['slider-scale', 'slider-opacity'],
getScale: (id) => { const e = _stageLights.get(id); return e ? e.state.indicatorScale : 1; },
setScale: (id, v) => setStageLightState({ indicatorScale: v }, id),
getOpacity: (id) => { const e = _stageLights.get(id); return e ? e.state.indicatorOpacity : 1; },
setOpacity: (id, v) => setStageLightState({ indicatorOpacity: v }, id),
});4. buildTransformCard 数据驱动化(223 行 → ≈45 行)
ts
export function buildTransformCard(container: HTMLElement, handle: ResourceHandle): void {
const { id, kind } = handle;
const adapter = getTransformAdapter(kind);
const render = (): void => {
container.innerHTML = '';
if (!adapter) return;
cardContainer(container, (c) => {
// ① Gizmo 拖拽行(唯一一行,替代 54 行三分支)
const active = isGizmoActive() && getGizmoTargetId() === id;
slideRow(c, active ? 'lucide:x' : 'lucide:move-3d',
t(active ? 'scene.exitDrag' : 'scene.dragPosition'), false, () => {
if (active) { detachGizmo(); setStatus(t('scene.statusExitDrag'), true); }
else { attachGizmoForKind(kind, id); setStatus(t('scene.statusDragHint'), false); }
render();
});
// ② 数值滑杆(能力声明式,替代 100 行两轮三分支)
if (adapter.capabilities.includes('slider-scale'))
addSliderRow(c, '缩放倍率', adapter.getScale!(id), 0.1, 10, 0.1, () => {}, 'lucide:maximize', (v) => adapter.setScale!(id, v));
if (adapter.capabilities.includes('slider-opacity'))
addSliderRow(c, '透明度', Math.round(adapter.getOpacity!(id) * 100), 0, 100, 1, () => {}, 'lucide:eye', (v) => adapter.setOpacity!(id, v / 100));
});
};
render();
}5. 拖拽 + 数值双模态(回应「纯拖拽不好」)
Phase 2 增量能力:在 transform-gizmo.ts 现有 onDragEndObservable 之外,为适配器补一个 onDragObservable(连续)回调 → 拖拽过程中实时刷新数值滑杆显示。数值滑杆的 onChange 也 funnel 到同一 adapter.setXxx。两条路径共享唯一状态来源,无漂移(满足 AGENTS.md「状态来源唯一」)。
拖拽(Gizmo 粗调) ─┐
├─→ adapter.setXxx(id, v) ─→ 持久化 + mesh 更新 ─→ 重渲染数值
数值滑杆(精调) ──┘关键不变量
transform-gizmo.ts核心单例契约不变:attachGizmo/detachGizmo/isGizmoActive/getGizmoTargetId签名与语义零改动。本 ADR 只在其上加适配层。- 各 kind 的持久化链路不变:
modelManager.setPosition/setScaling、setPropTransform、setStageLightState仍是唯一写入点,只是改由适配器统一 funnel 调用。 - Phase 1 行为零变化:纯去重重构,UI 与交互与现状逐像素一致;仅代码组织变化。
去重前后对比
| 维度 | 改造前 | 改造后(Phase 1) |
|---|---|---|
| Gizmo 包装函数 | 3 个 attachXxxGizmo(model-ops/props/lighting) | 1 个 attachGizmoForKind + 3 个声明式适配器对象 |
| re-export 风格 | 2 种不一致(别名 + 显式包装) | 0(统一走 transform-gizmo.ts 原函数) |
buildTransformCard | 223 行,9 段 if/else 分支 | ≈45 行,能力声明式循环渲染 |
| Gizmo 三分支 | ≈54 行 | 1 行 slideRow |
| 缩放/透明度三分支 | ≈100 行 | 2 行 if capabilities.includes |
lighting.ts 内联复制 | 1 处 direction→target 复制粘贴 | 0(内聚进适配器 onRotationDragEnd) |
| 新增可变换 kind 成本 | 改 4 处(新 attach 函数 + re-export + card 三处分支) | 改 1 处(注册 1 个适配器) |
| 可测试性 | 单例难测,buildTransformCard 逻辑厚重 | 适配器为纯对象可单测;card 逻辑变薄 |
总消除:3 函数 + 2 re-export 风格 + ≈154 行同构分支 + 1 处内联复制。
实施阶段
| 阶段 | 内容 | 涉及文件 | 验收 |
|---|---|---|---|
| Phase 1(去重,行为零变化) | 新建 scene/transform/transform-adapter.ts;三 kind 各注册适配器;buildTransformCard 数据驱动化;删除三 attachXxxGizmo + re-export;修 lighting.ts 内联复制 | transform-adapter.ts(新)、model-ops.ts、props.ts、lighting.ts、resource-detail-helpers.ts | 契约测试 + build + 手动逐 kind 回归 |
| Phase 2(双模态增强) | transform-gizmo.ts 补 onDragObservable 连续回调;拖拽实时同步数值滑杆(局部 DOM 更新,非整卡重渲染) | transform-gizmo.ts、transform-adapter.ts、resource-detail-helpers.ts | ✅ 拖拽中数值实时刷新,无跳变 |
| Phase 3(网格吸附) | transform-gizmo.ts 三 Gizmo 接线 snapDistance(position 场景单位 / rotation 派生 15° / scale 派生 0.1);新增 setGizmoSnapDistance/getGizmoSnapConfig 运行时配置;变换卡接入「网格吸附」开关 + 「吸附步长」滑杆 | transform-gizmo.ts、transform-adapter.ts、resource-detail-helpers.ts、i18n ×5 | ✅ 开关/步长实时生效,默认关闭零副作用 |
风险
| 级别 | 风险 | 缓解 |
|---|---|---|
| 🟢 P4(已验证安全) | stage 共用 modelRegistry:已 grep 核验——model-loader.ts:345 设 kind:'stage' 经 model-manager.ts:243 入同一 modelRegistry;attachModelGizmo(model-ops.ts:193)内部无 kind 分叉,actor/stage 走同一函数。结论:原 P2 风险解除,适配器 kinds:['actor','stage'] 安全 | 无需缓解;保留核验记录备查 |
| 🟡 P2(真实难点,已隔离) | prop 连续 opacity 不可直接统一:actor/stage 走 material.alpha(连续 0..1);prop 仅 visible 布尔(props.ts:229/259-266,setEnabled 实现)。当前 prop 透明度分支 buildTransformCard:186-203 为布尔可见(步长 100),适配器将其映射为 getOpacity 返回 0 或 1;若需连续透明度需给 PropInstance 增 opacity 字段 + 跨 meshes/container 逐材质 alpha 管线(共享材质 clone、transparencyMode、shader 忽略 alpha 等),属 prop 数据模型/材质工程 | Phase 1 保持布尔映射:适配器 getOpacity 返回 0/1,setOpacity 写 visible 布尔,行为与现状一致。连续透明若要做,单列 prop 材质增强 ADR,不纳入本 ADR |
| 🟢 P3 | 循环依赖风险:transform-adapter.ts 若静态 import 各 kind 模块会成环。应由各 kind 模块反向注册(import adapter registry,而非 registry import 各模块) | 遵循 ADR-121 依赖方向:registry 只定义接口 + Map,各模块单向注册。参照工程铁律「motion-modules 禁静态 import UI 层」 |
| 🟢 P3 | 注册时机:适配器在模块 import 副作用中注册,若 buildTransformCard 先于 kind 模块加载则拿到 null | resource-detail-helpers.ts 已 import 三 kind 模块(现状即如此),import 图保证注册先行;getTransformAdapter 返回 null 时 card 安全空渲染 |
| 🟢 P4 | transform-gizmo.ts 仍无 disposeTransformGizmo():detachGizmo 不清 _scene 引用 | 非本 ADR 范围,登记为独立设计债(可挂 ADR-104 design-debt-registration) |
验证清单
npm run test -- src/__tests__/bindings/app.contract.test.ts(116 函数存在性契约,确认删除 attachXxxGizmo 不破绑定——注:这些是纯前端函数,非 Wails 绑定,契约应不受影响,跑一遍确认)。cd frontend && npm run build。- 手动逐 kind 回归:actor / stage / prop / light 各自的拖拽进入/退出、缩放、透明度行为与改造前一致。
- 为
transform-adapter.ts新增单测(适配器纯对象 → mock registry 验证 getNode/gizmoTypes/getScale 等)。
备选方案(已否决)
| 方案 | 否决理由 |
|---|---|
| 仅抽 GizmoAdapter(窄口径) | 只消除 Gizmo 54 行,遗漏缩放/透明度 100 行同构;buildTransformCard 仍臃肿 |
| 维持现状 + 注释标记 | 不解决「新增 kind 改 4 处」的线性成本,违反「显著重复」反模式 |
| 每 kind 独立 TransformCard 组件 | 过度拆分,反而增加 UI 布局重复;违背「交互一致性」(同类操作应复用同一组件) |
实施记录
Phase 1(去重,行为零变化)— 已完成 2026-07-18
改动文件(5 个):
- 新增
scene/transform/transform-adapter.ts:定义TransformAdapter接口、adaptersMap 注册表、registerTransformAdapter/getTransformAdapter/attachGizmoForKind,并透传detachGizmo/isGizmoActive/getGizmoTargetId。 scene/manager/model-ops.ts:actor/stage 注册同一适配器(kinds:['actor','stage']);删除attachModelGizmo+ re-export 别名。scene/env/props.ts:prop 注册适配器;删除attachPropGizmo+ re-export 别名。scene/render/lighting.ts:light 注册适配器(direction→target转换内聚进onRotationDragEnd);删除attachLightGizmo/detachLightGizmo/包装函数;修复内联复制(类型切换 re-attach)改为attachGizmoForKind('light', targetId)。menus/resource-detail-helpers.ts:buildTransformCard由 223 行数据驱动化为 ~57 行;删除全部attachXxxGizmo系列导入。
关键修正(实施中发现):
- prop/light 适配器必须声明
capabilities:['slider-scale','slider-opacity']——现状变换卡中 prop/light 同样有缩放/透明度滑杆(原 draft 误判为仅 actor/stage 有)。get/set 精确 funnel 到现存setPropTransform({scaling}/{visible})与getStageLightState().indicatorScale/indicatorOpacity,行为零变化。
验证:
npm run build(tsc + vite)通过,3.69s,无类型/导入错误。- 契约测试
app.contract.test.ts17/17 通过(前端重命名未破 116 函数存在性)。 - 悬空引用扫描:0 处残留旧函数名。
- 单元套件 1572/1577 通过;5 失败均在
scene-stage.test.ts(地面/水面 toggle 用例,importbuildStageLevel来自未改动的scene-stage-levels.ts),与本次改造零交集,为既有失败。
Phase 2(双模态增强:拖拽实时同步数值滑杆):✅ 已完成 2026-07-18
改动文件(4 个,含 1 新单测):
scene/render/transform-gizmo.ts:- 新增模块级
onGizmoDragObservable = new Observable<void>()(连续拖拽信号)。 - 三个 Gizmo 案例各自额外接线
g.onDragObservable.add(() => onGizmoDragObservable.notifyObservers());(与既有onDragEndObservable并列)。 - 新增查询函数
getGizmoNode(): Node | null(返回实时节点,拖拽中其 transform 已被 Babylon 改写)与getActiveGizmoTypes(): GizmoType[](当前激活轴,用于判断是否在改缩放)。
- 新增模块级
scene/transform/transform-adapter.ts:透传onGizmoDragObservable / getGizmoNode / getActiveGizmoTypes(调用方从本模块统一 import)。menus/resource-detail-helpers.ts:buildTransformCard接入双模态——订阅onGizmoDragObservable,拖拽中经syncLive调用局部updateSliderDisplay(row, v, min, max, step)实时刷新缩放/透明度滑杆显示(与ui-rows.ts addSliderRow的updateDisplay显示格式一致,避免整卡 60Hz 重渲染导致的跳变);模块级_activeDragObs保证订阅全局唯一,退出拖拽/切换实体时自清理。scene/transform/transform-adapter.test.ts(新增):注册表隔离单测 4 项(未注册返回 null / actor+stage 共享同一适配器 / getScale·getOpacity funnel / 重注册覆盖)。
关键设计纠偏(实施中发现,重要):
- ⚠️ 不可连续持久化:初版计划是拖拽中连续调用
adapter.onScaleDrag → setScaling实时持久化。但核查model-manager.ts:514 setScaling每次调用末尾执行this.triggerAutoSave()→ 60Hz 拖拽将触发场景序列化落盘风暴(灾难性回归)。故改为只读实时节点、不连续持久化:拖拽中读取getGizmoNode().scaling(Babylon 已实时改写),仅同步滑杆显示;持久化仍在拖拽结束(onScaleDragEnd)一次性发生。这仍满足「实时刷新数值 + 两条路径共享唯一状态来源(结束点一致 funnel 到 setScaling)」,且彻底规避自动保存风暴。 - 局部
updateSliderDisplay仅在「缩放 Gizmo 激活(getActiveGizmoTypes含scale)」时读实时node.scaling.x;position/rotation 拖拽及 prop/light(无 scale 轴)回落到adapter.getScale(registry 值,稳定),故不会改变非缩放数值的显示。
验证:
npm run build(tsc + vite)3.78s exit0。- 契约测试
app.contract.test.ts17/17。 - 适配器单测
transform-adapter.test.ts4/4。 - 完整单元套件 1576/1581 通过;5 失败仍仅在
scene-stage.test.ts(地面/水面 toggle,import 未改动的scene-stage-levels.ts),与 Phase 2 零交集,属既有失败。 - 悬空引用:旧
attachXxxGizmo系列 0 残留。
Phase 3(网格吸附):✅ 已完成 2026-07-18
改动文件(7 个,含 1 新单测):
scene/render/transform-gizmo.ts:- 模块级
_snapEnabled/_snapStep(默认关闭、步长 1.0)。 - 私有
_snapFor(type):将单一step(场景单位)派生到三轴——position=step、rotation=step·π/12(step=1→15°)、scale=step·0.1(step=1→0.1)。snapDistance=0即 Babylon 禁用吸附,故默认关闭零副作用。 - 三 Gizmo 创建后各自
g.snapDistance = _snapFor(type)(与attachedNode并列)。 - 新增
setGizmoSnapDistance(enabled, step?)(实时作用于当前激活 Gizmo,无需 re-attach)+getGizmoSnapConfig()。
- 模块级
scene/transform/transform-adapter.ts:透传上述两 API(调用方从本模块统一 import)。menus/resource-detail-helpers.ts:变换卡接入吸附 UI——「网格吸附」addToggleRow(testIdtransform:snap-toggle)+ 启用时「吸附步长」addSliderRow(testIdtransform:snap-step,范围 0.1–5);两者均 funnel 到setGizmoSnapDistance。开关切换触发render()以增减步长滑杆;步长滑杆实时写入不重渲染。core/i18n/locales/*.ts(zh-CN/zh-TW/en/ja/ko ×5):新增scene.snapEnable/scene.snapStep两 key(满足国际化完整性)。scene/render/transform-gizmo.test.ts(新增):吸附配置隔离单测 4 项(默认/启用保留步长/更新步长/关闭保留步长)。
设计要点:
- 吸附是全局拖拽偏好(非 per-kind),常驻变换卡,下次/当前 Gizmo 均生效;所有 4 个 kind 均含 position 轴,故开关对全部资源有意义。
- 单一
step派生三轴步长,避免多滑杆歧义;语义对齐用户直觉(position 网格对齐为主,rotation/scale 按比例派生)。 - 默认关闭 → 既有拖拽手感逐像素不变(行为零变化不变量在默认态成立)。
验证:
npm run build(tsc + vite)3.87s exit0。- 契约测试
app.contract.test.ts17/17。 - 吸附单测
transform-gizmo.test.ts4/4。 - 跨测试污染甄别:完整套件报告 13 失败(scene-stage 5 基线 + motion-history 8),后者源文件
motion-history.ts/motion-history.test.ts均为未跟踪并发改动(adr-125 motion-undo-redo 半完成状态),隔离运行motion-history.test.ts14/14 通过,与 Phase 3 零交集;Phase 3 改动文件(transform-gizmo/adapter/resource-detail-helpers/i18n)未触碰 motion,判定非本特性回归。
Phase 3 代码审核(2026-07-18,结论:修复 P1 后「有条件通过」)
审核识别 1 处 🔴P1 功能性 Bug + 2 处 🟢P4(低风险建议),设计质量其余项良好。
- 🔴 P1 — 吸附步长滑杆死回调(
menus/resource-detail-helpers.ts):addSliderRow第 7 形参为必填onChange,原代码把() => {}置于onChange、第 9 形参onDragEndCb为undefined→ 两回调均未接真逻辑,用户拖拽步长滑块不产生任何效果(不更新模块状态、不入持久化),滑杆沦为装饰品。- 修复(遵循审核精确 diff):
onChange改为(v) => setGizmoSnapDistance(true, v)。语义上此时snap.enabled必为true(步长滑杆仅在if (snap.enabled)内渲染),(true, v)保持启用并实时更新步长,下次 Gizmo 拖拽即以新步长吸附。
- 修复(遵循审核精确 diff):
- 🟢 P4 #1 —
_snapFor派生逻辑无单测覆盖:原_snapFor为私有函数不可测。修复:抽离纯函数computeSnapDistance(type, enabled, step)并令_snapFor委托,新增 4 条派生单测(position→step / rotation→step·π/12 / scale→step·0.1 / enabled=false→0),transform-gizmo.test.ts由 4 项扩至 8 项全过。 - 🟢 P4 #2 — snap 透传职责漂移:
setGizmoSnapDistance/getGizmoSnapConfig经transform-adapter.ts透传,严格属纯 Gizmo 层功能、非 kind 适配职责。采纳审核建议「无需修复」,仅备注:若transform-adapter.ts后续变厚,可独立为scene/transform/gizmo-snap.ts避免职责漂移。
顺带审计(buildTransformCard 全卡回调排查):除 P1 吸附步长外,其余回调均正确——吸附开关 onChange→setGizmoSnapDistance(v,snap.step)+render();缩放/透明度滑杆的 onChange=()=>{} 为预期模式(live 无副作用,commit 放 onDragEndCb,与 Phase 2 已论证的 triggerAutoSave 风暴规避一致),无第二处死回调。
审核后验证:npm run build 3.93s exit0;吸附+派生单测 8/8;契约 17/17。