feat: resolve topology reference candidates

This commit is contained in:
2026-08-03 01:28:16 -04:00
parent ffbe2c69dc
commit 56903dbeb2
12 changed files with 160 additions and 22 deletions

View File

@@ -18,7 +18,7 @@
},
"facadeCapabilities": {
"geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "object-placement-recompute", "placement-validation", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
"document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "signature-topology-history-fallback", "undo-redo"] },
"document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "undo-redo"] },
"persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v6", "object-topology-snapshots", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] },
"sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "stable-external-geometry-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] },
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }

View File

@@ -1,6 +1,6 @@
# Web FreeCAD 接续执行状态
更新时间2026-08-03TSN generation 写回里程碑完成,待提交)
更新时间2026-08-03TopoRef 候选替换里程碑完成,待提交)
## 1. 当前目标
@@ -10,9 +10,9 @@
- 分支:`main`
- 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git`
- 最近已推送里程碑:`501f43f feat: apply object placement during recompute`
- 最近已推送里程碑:`ffbe2c6 feat: persist generation topology history`
- 最近已推送里程碑的完整门禁:`./npmw run verify` 通过
- 当前 TSN 未提交工作区验证:`./npmw run verify` 通过Facade 63/63diff/JSON 校验通过
- 当前候选替换未提交工作区验证:`./npmw run verify` 通过Facade 64/64diff/JSON 校验与桌面/移动截图通过
- 生产构建:通过
- 本地开发地址:`http://127.0.0.1:5173/`
- 兼容真值表:`config/compatibility-matrix.json`
@@ -29,6 +29,7 @@
7. Vector、Placement、LinkList/StringList 结构化属性合同Placement 深拷贝、校验、撤销、持久化和 UI 编辑器。
8. 对象级 Placement 重算:基本体/特征 Shape 生成后执行平移与轴角旋转;恒等变换跳过额外 Shape变换失败释放临时 Shape 并保留上一次有效缓存;保存后恢复的 Placement 可再次驱动重算。
9. TSN generation 写回:重算成功后采集面/边/点签名快照与保守历史;迁移 LinkSub/草图外部几何歧义或删除引用标脏所有者并生成可定位诊断schema v6 保存对象拓扑快照。
10. TopoRef 候选替换事务:属性面板/诊断页从源对象当前同 kind 拓扑候选中原子替换 LinkSub 或外部几何引用,重建 DAG、标脏重算并支持 Undo/Redo。
相关提交按新到旧:
@@ -57,7 +58,7 @@
1. 补齐 P2 浏览器实证门禁:真实 SQLite v4→v5 升级、OPFS 孤儿清扫、QuotaExceeded、大文件和崩溃注入合同层与 fallback 测试已由 `6bfbdf5` 完成。
2. 补齐 P3-03 浏览器 OCCT 包围盒/体积黄金测试,并验证 Placement 撤销、保存和恢复的真实 Worker 路径Node/fallback 合同已通过。
3. 为 ambiguous/deleted TopoRef 补 3D 候选高亮和用户替换事务,并增加真实 OCCT/SQLite 浏览器回放;当前仅有 Facade 诊断与选择所有者流程
3. 为 ambiguous/deleted TopoRef 补 3D 候选高亮,并增加真实 OCCT/SQLite 浏览器回放;候选替换 Facade 事务和属性/诊断 UI 已进入当前里程碑
4. 在获得 FreeCAD `1.1.1` 源码/构建输入后完成 `SK-03` planegcs WASM POC此前继续补充回放夹具、约束分类和 Sketch 编辑事务。
5. 扩展 PartDesignMirrored、MultiTransform、Loft/Pipe/Groove、Thickness/Draft以及 Pattern/Hole 完整语义。
6. 完成 FCStd 原生对象/属性/Shape 双向映射和 STEP/IGES/STL/Web CAD 包导入导出。
@@ -112,8 +113,17 @@ Placement 里程碑涉及实现、测试、兼容矩阵和三份状态/方案文
- 每个对象快照记录 generation、文档版本、迁移 matches 和 `signature-fallback` 历史;拓扑采集失败释放本轮 Shape最近有效缓存不被覆盖。
- 重算提交遍历 `App::PropertyLinkSub` 与 Sketch 外部几何:稳定引用写回新 generation歧义/删除引用不强绑,所有者保持 dirty/warning 并产生 `TOPOLOGY_REFERENCE_AMBIGUOUS/DELETED` 诊断。
- schema v6 新增 `objects.topology_json`fallback 保存/加载已验证快照深克隆;真实 SQLite/OPFS v5→v6 升级尚未进行浏览器实证。
- 完整 `./npmw run verify`、diff/JSON 校验均通过;提交与推送仍待执行
- 完整 `./npmw run verify`、diff/JSON 校验均通过;里程碑已由 `ffbe2c6` 推送
这仍不是 OCCT/FreeCAD 原生历史:签名来自三角网格,缺少曲面/曲线参数、邻接图和 Boolean 的逐输入原生历史。诊断能定位引用所有者,但 3D 候选高亮与原子替换 UI 尚未实现,因此 F3/TSN-03/07/09 不能标记完成。
这仍不是 OCCT/FreeCAD 原生历史:签名来自三角网格,缺少曲面/曲线参数、邻接图和 Boolean 的逐输入原生历史。候选替换事务/UI 已在下一里程碑补齐,但 3D 候选预选与高亮尚未实现,因此 F3/TSN-03/07/09 不能标记完成。
## 10. 2026-08-03 TopoRef 候选替换快照
- 新增 Facade `resolveTopologyReference()` 事务按所有者、PropertyLinkSub/ExternalGeometry 名称和候选 persistentId 定位引用;候选必须存在于源对象最新拓扑快照且 kind 一致。
- 用户明确选择后写入 stable TopoRef 和最新 generation重建 `topo-ref` DAG 边,标脏依赖闭包,更新文档版本并进入 Undo/Redo 与自动保存。
- 属性面板对 ambiguous/deleted LinkSub 显示候选菜单;诊断页对 LinkSub 和草图外部几何显示候选替换控件。非法候选拒绝,不修改文档。
- 完整 `./npmw run verify`、diff/JSON 校验通过Chrome 桌面/移动布局无重叠SwiftShader 延时截图确认 OCCT canvas 非空;提交和推送仍待执行。
尚未完成 3D 面/边/点候选预选与高亮,也没有真实 OCCT 浏览器黄金回放,因此 TSN-09/F3 仍保持进行中。
本里程碑不得声称 P3-03、F3 或完整 FreeCAD 兼容已经通过。当前对象 Placement 是 Shape 生成后的对象级变换,尚未证明 FreeCAD 对 Body/Tip、Support、AttachmentOffset 和局部坐标链的完整等价语义。

View File

@@ -310,7 +310,7 @@
属性领域合同现覆盖标量、枚举、颜色、Vector、Placement、ObjectLink、ObjectLinkList/StringList 和版本化 SubshapeLink。Placement/Vector 使用结构化值并做有限数、角度范围及非零旋转轴校验,复合值深拷贝进入 Undo/Redo 与持久化。Facade 的对象级几何重算已在 Shape 生成后执行 Placement并对恒等变换、畸形结构、变换失败释放和最近有效缓存保留给出测试证据这不是 FreeCAD Body/Tip、Support、AttachmentOffset 或特征局部坐标链的等价实现File/Shape 摘要和多选 mixed 编辑也仍未完成。
TSN 已完成面/边/顶点量化签名、唯一/重复候选判定和匹配 API并挂接到实际重算 generation。成功 Shape 会产生对象拓扑快照、迁移 matches 和 `signature-fallback` 历史;提交阶段自动写回 LinkSub 与草图外部几何,稳定引用更新 generation歧义/删除引用保持显式状态、标脏所有者并产生修复诊断。schema v6 通过 `objects.topology_json` 保存快照。版本化 TopoRef 仍拒绝 transient index当前没有 OCCT 原生 `Generated/Modified/Deleted`、曲线/曲面参数签名、邻接图、跨特征 Boolean 原生映射或 3D 候选替换,因此 TSN-03、TSN-07/09 与完整 TSN-08 仍未完成。
TSN 已完成面/边/顶点量化签名、唯一/重复候选判定和匹配 API并挂接到实际重算 generation。成功 Shape 会产生对象拓扑快照、迁移 matches 和 `signature-fallback` 历史;提交阶段自动写回 LinkSub 与草图外部几何,稳定引用更新 generation歧义/删除引用保持显式状态、标脏所有者并产生修复诊断。候选替换 Facade 事务验证当前拓扑和 kind 后原子写入 stable TopoRef重建 DAG、进入 Undo/Redo属性面板和诊断页均通过 Facade 调用。schema v6 保存快照。当前没有 OCCT 原生历史、曲线/曲面参数签名、邻接图、跨 Boolean 原生映射或 3D 候选高亮,因此 TSN-03、TSN-07/09 与完整 TSN-08 仍未完成。
Sketcher 已有 Facade 事务模型和基础求解器适配器Sketch 几何/约束快照进入 `DocumentObjectSnapshot`,新增几何、约束和求解会更新 `ConstraintStatus`、历史、自动保存与 SQLite schema v4基础约束现覆盖 Diameter、Symmetric、Tangent。该适配器的职责是验证数据模型和提供可替换协议它不宣称已经等价于 FreeCAD `planegcs`。SK-03 的 planegcs WASM、SK-07 的完整约束分类、SK-09 的交互回放和 SK-10 的 B-spline 仍是兼容门禁。
@@ -342,7 +342,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
| P3-05 Pattern 实验切片 | `linear-pattern``polar-pattern` 通过 Part Design 选择谓词、Task 和类型化属性创建对应对象Linear 按总 Length 等距平移Polar 在小于 360° 时覆盖首末角、360° 时不复制重合端点;两者均由 OCCT union 合并并释放临时副本 | 线性间距/方向、圆周完整/部分角度分布、联合输入、句柄释放、非法参数诊断、Base 链接、Body.Tip、属性校验和任务提交回归测试通过 | 当前为 FreeCAD 1.0+ 的 Transform body 近似,只复制整个 Base Shape未实现 Transform tool shapes、连接单实体过滤、稳定 TopoRef、历史映射、反向轴和引用轴因此保持 `experimental` |
| P3-05 Hole 基础切片 | `hole` 创建 `PartDesign::Hole`,用 32 边圆形 Profile 调用现有 OCCT Pocket支持 Diameter、Depth 以及 Dimension/Through all校验正尺寸并纳入 Base 链接和 Body.Tip | 圆孔 Profile、有限深度调用、非法直径诊断、Task 属性提交和 Tip 更新测试通过 | 仅为基体原点 XY 面、+Z 法向的直孔;草图圆/面定位、沉孔、锪孔、钻尖、螺纹、尺寸标准和稳定支撑面引用仍待实现,保持 `experimental` |
| TSN-02/05/06/08 TopoRef 基础迁移 | `topologyReferences` 提供 schema v1 创建、序列化、安全解析、解析和跨版本迁移;重复几何候选 ID 唯一,一旧多新不会错误继承;匹配按 kind 隔离并用形状尺度归一化几何评分支持纯平移 | round-trip、瞬时索引拒绝、resolved/ambiguous/deleted、重复拆分和纯平移回归测试通过 | 仍是网格签名启发式;没有 OCCT Generated/Modified/Deleted、邻接/曲率签名、布尔历史和黄金模型规模验证,不得提升为 compatible |
| TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef依赖图产生带 persistentId 的 `topo-ref` 边;重算 generation 自动迁移 LinkSub/草图外部几何,歧义/删除不强绑并产生定位诊断schema v6 保存对象拓扑快照Pocket 暴露 `UpToFace` 槽 | 非法瞬时索引/缺失对象拒绝、DAG 边、稳定/歧义/删除迁移、跨 generation 回退解析、保存加载 round-trip 和专项测试通过 | 尚未从 3D 选择创建/替换候选引用;诊断只能选择所有者,Pocket Up to face executor 仍保持结构化 unsupported等待原生面历史/解析接线 |
| TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef重算 generation 自动迁移 LinkSub/草图外部几何,歧义/删除不强绑schema v6 保存对象拓扑快照;Facade 候选替换事务验证当前同 kind persistentId重建 DAG 并支持 Undo/Redo属性/诊断 UI 可替换;Pocket 暴露 `UpToFace` 槽 | 非法瞬时索引/候选拒绝、DAG 边、稳定/歧义/删除迁移、跨 generation 回退解析、候选替换、Undo/Redo、保存加载和 64 项全套测试通过 | 尚未从 3D 拾取、预选或高亮候选;Pocket Up to face executor 仍保持结构化 unsupported等待原生面历史/解析接线 |
| TSN-03/07 历史适配器回退 | Bitbybit 1.1.1 绑定仅有 `HasGenerated()`,没有逐 Shape 的 Generated/Modified/IsDeleted`signature-fallback` 已进入真实重算 generation对每个成功 Shape 保存快照/matches/history采集失败释放临时 Shape并保留最近有效缓存 | 平移修改、新增/删除/重复面、跨对象竞争、generation 写回、采集失败隔离和持久化测试通过;全套 63 项测试通过 | 此 provider 不是 OCCT history多输入 Boolean 当前仍缺逐输入原生映射,只有自构建绑定暴露逐子形状历史并通过黄金模型后,才能将 TSN-03/07 标为完成 |
后续按以下顺序连续开发:

View File

@@ -1602,12 +1602,12 @@ P0 基线/治理
| P2-05 多标签页写入协调 | `DONE (降级可观测)` | `SqliteProjectPersistence` 在保存和资源写入外包 `navigator.locks` 独占锁,成功保存通过 `BroadcastChannel` 广播文档版本Facade 暴露 `project.subscribeExternalChanges()`;无 Web Locks 的运行时能力标记为 `local-queue`,不宣称跨标签页互斥 |
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放React 视口通过 Facade OCCT 预览链生成 Pad/Pocket Mesh |
| P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 |
| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle``MeshAsset``SubshapeRef` 类型;`geometry.topology()` 生成面/边/点签名条目,成功 generation 写入对象拓扑快照、迁移 matches 和保守历史;TopoRef schema v1 可序列化/解析/迁移并拒绝瞬时索引,重算提交自动迁移 LinkSub/外部几何,歧义/删除保持显式状态和诊断。OCCT 原生历史命名、邻接/曲率签名、跨 Boolean 映射和 3D 候选替换仍未实现 |
| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle``MeshAsset``SubshapeRef` 类型;成功 generation 写入对象拓扑快照、迁移 matches 和保守历史;重算自动迁移 LinkSub/外部几何,歧义/删除保持显式状态和诊断;候选替换事务只接受源对象最新同 kind persistentId属性/诊断 UI 可提交并支持 Undo/Redo。OCCT 原生历史、邻接/曲率签名、跨 Boolean 映射和 3D 候选高亮仍未实现 |
| P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和浏览器矩阵已验证基础 PlacementFacade 重算 executor 现在对对象 Placement 做严格结构校验,在 Shape 生成后执行平移/轴角旋转,恒等变换跳过额外句柄,失败时释放临时 Shape 并保留最近有效缓存Node/fallback 已覆盖失败释放、畸形输入和保存恢复。0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm自动化浏览器黄金/体积测试尚未进入 CI |
| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket/RevolutionRevolution Angle/Reversed 已接入Fillet/Chamfer 也有 Shape 缓存回写Up to face、FCStd Shape 持久化仍待完成 |
| P3-05 Fillet/Chamfer/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存Linear/Polar Pattern 已贯通选择谓词、Task、Placement/union 和清理;基础 Hole 用圆形 Profile 复用 Pocket支持 Diameter、Depth、Dimension/Through all。Pattern 当前为整 ShapeHole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 TopoRef、草图/面定位及沉孔/锪孔/螺纹仍待实现 |
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry视口优先使用重计算缓存的对象 Shape失败时保留最近有效结果无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 63 个场景,包括 Part/PartDesign、Pattern/Hole、TopoRef/LinkSub、generation 拓扑快照/迁移、Sketch provider、几何重算、诊断修复、FCStd 安全、schema migration、1000 次写队列、检查点和资源治理E2E/黄金几何待补齐 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 64 个场景,包括 Part/PartDesign、Pattern/Hole、TopoRef/LinkSub、generation 拓扑快照/迁移/候选替换、Sketch provider、几何重算、诊断修复、FCStd 安全、schema migration、1000 次写队列、检查点和资源治理E2E/黄金几何待补齐 |
本迭代验证命令:`npm run check:facade-boundary``npm run test:facade``npm run build`。构建产物将 Three.js 拆为独立 chunk避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。

View File

@@ -452,7 +452,16 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
editor = <select className="property-control property-link-control" value={String(property.value ?? '')} aria-label={property.label} onChange={(event) => commit(event.target.value || null)}><option value="">None</option>{ids.filter((id) => id !== objectId).map((id) => <option value={id} key={id}>{document.tree.find((item) => item.id === id)?.label ?? id}</option>)}</select>
} else if (property.type === 'App::PropertyLinkSub') {
const ref = property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'schemaVersion' in property.value ? property.value : null
editor = ref ? <div className="property-link-sub"><span title={ref.persistentId}>{ref.objectId} / {ref.kind} · {ref.status}</span><IconButton icon={X} label="Clear subshape reference" onClick={() => commit(null)} /></div> : <span className="property-readonly">None</span>
const source = ref ? facade.app.document.getObject(ref.objectId) : null
const candidates = ref ? (ref.candidates?.length ? ref.candidates : source?.topology?.entries.filter((entry) => entry.ref.kind === ref.kind && entry.ref.status !== 'deleted').map((entry) => entry.ref.persistentId) ?? []) : []
const replace = (candidatePersistentId: string) => {
if (!candidatePersistentId) return
try {
facade.app.document.resolveTopologyReference({ ownerObjectId: objectId, referenceName: property.name, candidatePersistentId })
void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? `${property.label} reference replaced` : `Reference replaced; recompute ${result.status}`))
} catch (error) { showNotice(error instanceof Error ? error.message : String(error)) }
}
editor = ref ? <div className="property-link-sub"><span title={ref.persistentId}>{ref.objectId} / {ref.kind} · {ref.status}</span>{(ref.status === 'ambiguous' || ref.status === 'deleted') && candidates.length > 0 ? <select className="property-control property-topology-candidates" aria-label={`${property.label} replacement`} defaultValue="" onChange={(event) => replace(event.target.value)}><option value="">Replace</option>{candidates.map((candidate) => <option value={candidate} key={candidate}>{candidate}</option>)}</select> : null}<IconButton icon={X} label="Clear subshape reference" onClick={() => commit(null)} /></div> : <span className="property-readonly">None</span>
} else if (property.type === 'App::PropertyPlacement') {
const placement = property.value as PlacementValue
const updatePlacement = (path: 'position' | 'axis' | 'angle', key: 'x' | 'y' | 'z' | null, next: number) => {
@@ -670,12 +679,22 @@ function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page
function DocumentDiagnosticNode({ node, facade, showNotice, child = false }: { node: DiagnosticTreeNode; facade: BitBybitWebCadFacade; showNotice: (message: string) => void; child?: boolean }) {
const diagnostic = node.diagnostic
const topologyRepair = diagnostic.topologyRepair
const [topologyCandidate, setTopologyCandidate] = useState(topologyRepair?.candidates[0] ?? '')
const runRepair = (actionId: 'select-object' | 'recompute-root' | 'suppress-root') => {
void facade.diagnostics.repair(diagnostic.id, actionId).then((result) => showNotice(result.message))
}
const replaceTopologyReference = () => {
if (!topologyRepair || !topologyCandidate) return
try {
facade.app.document.resolveTopologyReference({ ...topologyRepair, candidatePersistentId: topologyCandidate })
void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Topology reference replaced' : `Reference replaced; recompute ${result.status}`))
} catch (error) { showNotice(error instanceof Error ? error.message : String(error)) }
}
return <div className={`document-diagnostic ${child ? 'is-child' : ''}`}>
<div className="document-diagnostic-main"><span className={`diagnostic-severity ${diagnostic.severity}`}><AlertTriangle size={14} /></span><div><strong>{diagnostic.code}</strong><span>{diagnostic.message}</span><small>{diagnostic.objectId || diagnostic.source}{diagnostic.dependencyPath && diagnostic.dependencyPath.length > 1 ? ` · ${diagnostic.dependencyPath.join(' → ')}` : ''}{diagnostic.generation ? ` · generation ${diagnostic.generation}` : ''}</small></div></div>
{diagnostic.repairActions?.length ? <div className="diagnostic-actions">{diagnostic.repairActions.map((action) => <button key={action.id} className="button button-quiet" disabled={!action.enabled} title={action.reason || action.label} onClick={() => runRepair(action.id)}>{action.id === 'select-object' ? <Search size={13} /> : action.id === 'recompute-root' ? <RefreshCw size={13} /> : <Pause size={13} />}{action.label}</button>)}</div> : null}
{topologyRepair ? <div className="topology-repair"><select className="property-control" aria-label="Replacement subshape" value={topologyCandidate} onChange={(event) => setTopologyCandidate(event.target.value)}>{topologyRepair.candidates.map((candidate) => <option value={candidate} key={candidate}>{candidate}</option>)}</select><button className="button button-quiet" onClick={replaceTopologyReference} disabled={!topologyCandidate}><Check size={13} />Replace reference</button></div> : null}
{node.children.length > 0 ? <div className="diagnostic-children">{node.children.map((entry) => <DocumentDiagnosticNode key={entry.diagnostic.id} node={entry} facade={facade} showNotice={showNotice} child />)}</div> : null}
</div>
}

View File

@@ -15,6 +15,7 @@ export const cloneDiagnostic = (diagnostic: Diagnostic): Diagnostic => ({
...diagnostic,
dependencyPath: diagnostic.dependencyPath ? [...diagnostic.dependencyPath] : undefined,
repairActions: diagnostic.repairActions?.map(cloneAction),
topologyRepair: diagnostic.topologyRepair ? { ...diagnostic.topologyRepair, candidates: [...diagnostic.topologyRepair.candidates] } : undefined,
})
const rootCausePath = (objectId: string, graph: DependencyGraph, states: Record<string, RecomputeState>) => {

View File

@@ -7,9 +7,9 @@ export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION,
export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './projectSchema'
export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue } from './types'
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue } from './types'
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from './topologyReferences'
export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences'
export { captureSignatureTopologyHistory } from './topologyHistory'
export type { TopologyHistoryEntry, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory'

View File

@@ -19,6 +19,7 @@ import type {
SetPropertyInput,
SetExpressionInput,
RecomputeResult,
ResolveTopologyReferenceInput,
ShapeHandle,
TaskSnapshot,
Unsubscribe,
@@ -32,7 +33,7 @@ import { cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch,
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
import { inspectFcstdArchive } from './fcstd'
import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics'
import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef } from './topologyReferences'
import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences'
const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
@@ -464,6 +465,18 @@ export function createMockFacade(): BitBybitWebCadFacade {
commit({ ...state, document })
notify(`${sourceProperty.label} updated`)
}
const resolveTopologyReference = (input: ResolveTopologyReferenceInput) => {
const document = cloneDocumentSnapshot(state.document)
const resolved = resolveDocumentTopologyReference(document, input)
document.dependencies = collectDependencyEdges(document)
markDocumentTouched(document, [input.ownerObjectId])
document.version += 1
document.dirty = true
const diagnostics = state.diagnostics.filter((diagnostic) => diagnostic.topologyRepair?.ownerObjectId !== input.ownerObjectId || diagnostic.topologyRepair.referenceName !== input.referenceName)
commit({ ...state, document, diagnostics })
notify(`${input.referenceName} topology reference replaced`)
return { ...resolved, candidates: resolved.candidates ? [...resolved.candidates] : undefined }
}
const setExpression = ({ objectId, propertyName, expression }: SetExpressionInput) => {
const objectIndex = state.document.objects.findIndex((object) => object.id === objectId)
if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`)
@@ -566,7 +579,12 @@ export function createMockFacade(): BitBybitWebCadFacade {
if (status) status.value = topologyMigration.issues.some((issue) => issue.ownerObjectId === objectId) ? 'Topology reference requires repair' : 'Touched'
}
const nextDiagnostics = buildRecomputeDiagnostics({ document, generation: result.generation, affected: result.affected, objectStates: result.objectStates, errors: result.errors })
const topologyDiagnostics: Diagnostic[] = topologyMigration.issues.map((issue, index) => ({
const topologyDiagnostics: Diagnostic[] = topologyMigration.issues.map((issue, index) => {
const sourceTopology = document.objects.find((object) => object.id === issue.sourceObjectId)?.topology
const candidates = issue.candidates?.length
? issue.candidates
: sourceTopology?.entries.filter((entry) => entry.ref.kind === issue.kind && entry.ref.status !== 'deleted').map((entry) => entry.ref.persistentId) ?? []
return {
id: `topology:${document.id}:${result.generation}:${issue.ownerObjectId}:${issue.referenceName}:${index}`,
source: 'geometry',
severity: issue.status === 'deleted' ? 'error' : 'warning',
@@ -580,11 +598,12 @@ export function createMockFacade(): BitBybitWebCadFacade {
generation: result.generation,
rootCauseObjectId: issue.sourceObjectId,
dependencyPath: [issue.ownerObjectId, issue.sourceObjectId],
topologyRepair: candidates.length > 0 ? { ownerObjectId: issue.ownerObjectId, referenceName: issue.referenceName, candidates: [...new Set(candidates)] } : undefined,
repairActions: [
{ id: 'select-object', label: 'Select reference owner', targetObjectId: issue.ownerObjectId, enabled: true },
{ id: 'recompute-root', label: 'Recompute after replacing reference', targetObjectId: issue.ownerObjectId, enabled: false, reason: 'Select one current subshape and replace the ambiguous or deleted reference first.' },
],
}))
}})
const replaced = replaceRecomputeDiagnostics(state.diagnostics, document.id, result.affected, nextDiagnostics)
const topologyOwners = new Set(topologyMigration.issues.map((issue) => issue.ownerObjectId))
const diagnostics = [...replaced.filter((diagnostic) => !diagnostic.code.startsWith('TOPOLOGY_REFERENCE_') || diagnostic.documentId !== document.id || !diagnostic.objectId || !topologyOwners.has(diagnostic.objectId)), ...topologyDiagnostics]
@@ -738,7 +757,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
}
const facade: BitBybitWebCadFacade = {
app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, resolveTopologyReference, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } },
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },

View File

@@ -1,4 +1,4 @@
import type { DocumentSnapshot, ObjectTopologySnapshot, SubshapeRef, TopoRefValue } from './types'
import type { DocumentSnapshot, ObjectTopologySnapshot, ResolveTopologyReferenceInput, SubshapeRef, TopoRefValue } from './types'
import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming'
export type PersistedTopoRef = TopoRefValue
@@ -19,6 +19,7 @@ export type TopologyReferenceMigrationIssue = {
ownerObjectId: string
sourceObjectId: string
referenceName: string
kind: SubshapeRef['kind']
status: 'ambiguous' | 'deleted'
persistentId: string
candidates?: string[]
@@ -166,7 +167,7 @@ export const migrateDocumentTopologyReferences = (
if (!source) return record
const migrated = migrateReferenceThroughSnapshot(record, source)
if (migrated.generation !== record.generation || migrated.status !== record.status || migrated.persistentId !== record.persistentId) changedOwnerIds.add(ownerObjectId)
if (migrated.status === 'ambiguous' || migrated.status === 'deleted') issues.push({ ownerObjectId, sourceObjectId: record.objectId, referenceName, status: migrated.status, persistentId: migrated.persistentId, candidates: migrated.candidates ? [...migrated.candidates] : undefined })
if (migrated.status === 'ambiguous' || migrated.status === 'deleted') issues.push({ ownerObjectId, sourceObjectId: record.objectId, referenceName, kind: record.kind, status: migrated.status, persistentId: migrated.persistentId, candidates: migrated.candidates ? [...migrated.candidates] : undefined })
return migrated
}
for (const object of document.objects) {
@@ -178,3 +179,32 @@ export const migrateDocumentTopologyReferences = (
}
return { changedOwnerIds: [...changedOwnerIds], issues }
}
export const resolveDocumentTopologyReference = (
document: DocumentSnapshot,
input: ResolveTopologyReferenceInput,
): PersistedTopoRef => {
const owner = document.objects.find((object) => object.id === input.ownerObjectId)
if (!owner) throw new Error(`Topology reference owner does not exist: ${input.ownerObjectId}`)
let current: PersistedTopoRef | null = null
let replace: ((record: PersistedTopoRef) => void) | null = null
if (input.referenceName.startsWith('ExternalGeometry:')) {
const externalId = input.referenceName.slice('ExternalGeometry:'.length)
const external = owner.sketch?.externalGeometry.find((candidate) => candidate.id === externalId)
if (external) { current = external.source; replace = (record) => { external.source = record } }
} else {
const property = owner.properties.find((candidate) => candidate.name === input.referenceName && candidate.type === 'App::PropertyLinkSub')
if (property?.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'schemaVersion' in property.value) {
current = property.value
replace = (record) => { property.value = record }
}
}
if (!current || !replace) throw new Error(`Topology reference does not exist: ${input.ownerObjectId}.${input.referenceName}`)
const source = document.objects.find((object) => object.id === current?.objectId)
if (!source?.topology) throw new Error(`Source object has no current topology snapshot: ${current.objectId}`)
const candidate = source.topology.entries.find((entry) => entry.ref.persistentId === input.candidatePersistentId && entry.ref.kind === current?.kind)
if (!candidate) throw new RangeError(`Topology candidate is not available for ${input.referenceName}: ${input.candidatePersistentId}`)
const resolved = createPersistedTopoRef(current.objectId, { ...candidate.ref, status: 'stable', candidates: undefined }, source.topology.generation)
replace(resolved)
return resolved
}

View File

@@ -90,6 +90,12 @@ export type SetPropertyInput = {
value: PropertyValue
}
export type ResolveTopologyReferenceInput = {
ownerObjectId: string
referenceName: string
candidatePersistentId: string
}
export type PersistenceCapabilities = {
mode: 'sqlite-opfs' | 'sqlite-memory' | 'unavailable'
sqliteWasm: boolean
@@ -381,6 +387,11 @@ export type Diagnostic = {
rootCauseObjectId?: string
dependencyPath?: string[]
repairActions?: DiagnosticRepairAction[]
topologyRepair?: {
ownerObjectId: string
referenceName: string
candidates: string[]
}
resolved?: boolean
}
@@ -457,6 +468,7 @@ export interface BitBybitWebCadFacade {
load(documentId: string): Promise<DocumentSnapshot | null>
markDirty(): void
setProperty(input: SetPropertyInput): void
resolveTopologyReference(input: ResolveTopologyReferenceInput): TopoRefValue
setExpression(input: SetExpressionInput): void
recompute(): RecomputeResult
recomputeAsync(options?: RecomputeExecutionOptions): Promise<RecomputeExecutionResult>

View File

@@ -378,8 +378,10 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.property-number .property-control { min-width: 0; }
.property-link-control { color: var(--cyan); }
.property-link-sub { display: flex; min-width: 0; align-items: center; gap: 4px; color: var(--cyan); font-size: 10px; }
.property-link-sub > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.property-link-sub > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.property-link-sub .icon-button { width: 24px; height: 24px; flex: 0 0 24px; }
.property-topology-candidates { min-width: 0; max-width: 112px; }
.topology-repair { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; margin: 8px 0 0 34px; }
.property-color { display: flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; }
.property-color input { width: 22px; height: 18px; padding: 0; border: 1px solid var(--line); border-radius: 2px; background: transparent; }
.property-expression { min-height: 20px; display: flex; align-items: center; justify-content: flex-end; gap: 5px; color: var(--cyan); font-size: 9px; }

View File

@@ -7,7 +7,7 @@ import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS
import { DependencyGraph } from '../src/facade/dependencyGraph'
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
import { createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences'
import { createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences'
import { captureSignatureTopologyHistory } from '../src/facade/topologyHistory'
import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy'
import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher'
@@ -887,6 +887,13 @@ test('generation topology migration updates LinkSub and external geometry withou
assert.equal(external?.candidates?.length, 2)
assert.deepEqual(ambiguous.changedOwnerIds, ['owner'])
assert.deepEqual(ambiguous.issues.map((issue) => issue.referenceName), ['SupportFace', 'ExternalGeometry:external'])
const propertyCandidate = external?.candidates?.[0] as string
const resolvedProperty = resolveDocumentTopologyReference(document, { ownerObjectId: 'owner', referenceName: 'SupportFace', candidatePersistentId: propertyCandidate })
assert.equal(resolvedProperty.status, 'stable')
assert.equal(resolvedProperty.generation, 2)
const externalCandidate = external?.candidates?.[1] as string
assert.equal(resolveDocumentTopologyReference(document, { ownerObjectId: 'owner', referenceName: 'ExternalGeometry:external', candidatePersistentId: externalCandidate }).persistentId, externalCandidate)
assert.throws(() => resolveDocumentTopologyReference(document, { ownerObjectId: 'owner', referenceName: 'SupportFace', candidatePersistentId: 'missing' }), /not available/)
const deletedMigration = migrateTopoRefs('source', previousEntries, [], 3)
document.objects[0].topology = {
@@ -916,6 +923,44 @@ test('generation topology migration updates LinkSub and external geometry withou
assert.equal(resolvedAcrossGap.issues.length, 1)
})
test('Facade topology replacement is versioned, rebuilds dependencies and is undoable', async () => {
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
const current = createSubshapeRefs('shape-current', 5, [face, face])
const entries = current.refs.map((ref, index) => ({ ref, signature: current.signatures[index] }))
const migration = migrateTopoRefs('source', [], entries, 5)
const candidateIds = migration.matches.map((match) => match.current.persistentId)
const reference = createPersistedTopoRef('source', { ...current.refs[0], status: 'ambiguous', candidates: candidateIds }, 4)
const topology: ObjectTopologySnapshot = {
shapeId: 'shape-current', documentVersion: 5, generation: 5, entries,
migration: { previousGeneration: 4, matches: migration.matches },
history: captureSignatureTopologyHistory('source:generation:5', [], entries),
}
const document: DocumentSnapshot = {
...recomputeDocumentFixture(), id: 'doc-topology-repair', version: 5,
tree: [{ id: 'source', label: 'Source', type: 'feature' }, { id: 'owner', label: 'Owner', type: 'feature' }],
objects: [
{ id: 'source', typeId: 'Part::Box', properties: [], topology },
{ id: 'owner', typeId: 'PartDesign::Feature', properties: [{ name: 'SupportFace', label: 'Support face', group: 'Attachment', scope: 'data', type: 'App::PropertyLinkSub', value: reference, recompute: true }] },
], dependencies: [{ sourceId: 'owner', targetId: 'source', relation: 'topo-ref', propertyName: 'SupportFace', reference: reference.persistentId }],
recompute: { generation: 5, status: 'idle', objectStates: { source: 'up-to-date', owner: 'touched' }, dirtyObjects: ['owner'], order: [], errors: [] },
}
const facade = createMockFacade()
await facade.project.save(document)
await facade.app.document.load(document.id)
const versionBefore = facade.app.document.getActive().version
const resolved = facade.app.document.resolveTopologyReference({ ownerObjectId: 'owner', referenceName: 'SupportFace', candidatePersistentId: candidateIds[1] })
assert.equal(resolved.status, 'stable')
assert.equal(resolved.persistentId, candidateIds[1])
assert.equal(facade.app.document.getActive().version, versionBefore + 1)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getDependencies().find((edge) => edge.propertyName === 'SupportFace')?.reference, candidateIds[1])
facade.history.undo()
assert.equal((facade.app.document.getObject('owner')?.properties[0].value as typeof reference).status, 'ambiguous')
facade.history.redo()
assert.equal((facade.app.document.getObject('owner')?.properties[0].value as typeof reference).persistentId, candidateIds[1])
facade.geometry.dispose()
})
test('facade async recompute commits only an accepted generation', async () => {
const facade = createMockFacade()
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })