diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index ba0319a..797cd54 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -18,8 +18,8 @@ }, "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", "signature-topology-history-fallback", "undo-redo"] }, - "persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v5", "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"] }, + "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"] }, + "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"] } }, diff --git a/docs/continuation-status.zh-CN.md b/docs/continuation-status.zh-CN.md index f947822..256a848 100644 --- a/docs/continuation-status.zh-CN.md +++ b/docs/continuation-status.zh-CN.md @@ -1,6 +1,6 @@ # Web FreeCAD 接续执行状态 -更新时间:2026-08-03(对象 Placement 重算里程碑已推送) +更新时间:2026-08-03(TSN generation 写回里程碑完成,待提交) ## 1. 当前目标 @@ -12,7 +12,7 @@ - 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git` - 最近已推送里程碑:`501f43f feat: apply object placement during recompute` - 最近已推送里程碑的完整门禁:`./npmw run verify` 通过 -- 当前 Placement 里程碑验证:`./npmw run verify` 通过(Facade 61/61),diff/JSON 校验通过 +- 当前 TSN 未提交工作区验证:`./npmw run verify` 通过(Facade 63/63),diff/JSON 校验通过 - 生产构建:通过 - 本地开发地址:`http://127.0.0.1:5173/` - 兼容真值表:`config/compatibility-matrix.json` @@ -28,6 +28,7 @@ 6. 外部草图几何、稳定 TopoRef、持久化与 `topo-ref` DAG 依赖边。 7. Vector、Placement、LinkList/StringList 结构化属性合同;Placement 深拷贝、校验、撤销、持久化和 UI 编辑器。 8. 对象级 Placement 重算:基本体/特征 Shape 生成后执行平移与轴角旋转;恒等变换跳过额外 Shape;变换失败释放临时 Shape 并保留上一次有效缓存;保存后恢复的 Placement 可再次驱动重算。 +9. TSN generation 写回:重算成功后采集面/边/点签名快照与保守历史;迁移 LinkSub/草图外部几何;歧义或删除引用标脏所有者并生成可定位诊断;schema v6 保存对象拓扑快照。 相关提交按新到旧: @@ -56,7 +57,7 @@ 1. 补齐 P2 浏览器实证门禁:真实 SQLite v4→v5 升级、OPFS 孤儿清扫、QuotaExceeded、大文件和崩溃注入;合同层与 fallback 测试已由 `6bfbdf5` 完成。 2. 补齐 P3-03 浏览器 OCCT 包围盒/体积黄金测试,并验证 Placement 撤销、保存和恢复的真实 Worker 路径;Node/fallback 合同已通过。 -3. 继续 TSN:把拓扑快照和历史结果接入实际重算 generation,迁移外部几何与 LinkSub,暴露 ambiguous/deleted 修复流程。 +3. 为 ambiguous/deleted TopoRef 补 3D 候选高亮和用户替换事务,并增加真实 OCCT/SQLite 浏览器回放;当前仅有 Facade 诊断与选择所有者流程。 4. 在获得 FreeCAD `1.1.1` 源码/构建输入后完成 `SK-03` planegcs WASM POC;此前继续补充回放夹具、约束分类和 Sketch 编辑事务。 5. 扩展 PartDesign:Mirrored、MultiTransform、Loft/Pipe/Groove、Thickness/Draft,以及 Pattern/Hole 完整语义。 6. 完成 FCStd 原生对象/属性/Shape 双向映射和 STEP/IGES/STL/Web CAD 包导入导出。 @@ -81,7 +82,7 @@ git log -5 --oneline ## 7. 2026-08-03 P2 接续进展 -本轮已完成 schema v5 迁移运行器、最近 5 版文档检查点、`loadCheckpoint()`、配额预检、资源清扫计划/API 和 1000 次写队列压力实现。完整门禁已通过,并由提交 `6bfbdf5` 推送到云仓库。 +本轮已完成 schema v5 迁移运行器、最近 5 版文档检查点、`loadCheckpoint()`、配额预检、资源清扫计划/API 和 1000 次写队列压力实现。完整门禁已通过,并由提交 `6bfbdf5` 推送到云仓库。后续 TSN 里程碑把 schema 提升到 v6,以 `objects.topology_json` 保存 generation 拓扑快照;真实浏览器 v5→v6 升级仍需补证。 证据边界:Node/fallback 证明迁移合同、队列、检查点与纯资源策略;真实浏览器 SQLite v4→v5 升级、OPFS 孤儿清扫、QuotaExceeded、大文件与崩溃注入仍是 P2/P8 后续任务,不能因本轮测试而标记完整 G4/G5。 @@ -103,6 +104,16 @@ Placement 里程碑涉及实现、测试、兼容矩阵和三份状态/方案文 完整 `./npmw run verify`、`git diff --check` 和 `jq empty config/compatibility-matrix.json` 均已通过;Placement 已作为独立里程碑提交并推送到远端 `main`。 1. 浏览器 OCCT 包围盒黄金证据若无法自动化,保持 P3-03 为 `IN PROGRESS`。 -2. 接着推进 TSN generation 历史写回,不把签名回退误标为 OCCT 原生历史。 +2. TSN generation 历史写回已进入当前工作区;提交后推进候选替换事务与下一批 PartDesign 特征,不把签名回退误标为 OCCT 原生历史。 + +## 9. 2026-08-03 TSN generation 写回快照 + +- `geometry.topology()` 同时返回可序列化的面/边/点签名条目;几何执行器只在 generation 被接受且拓扑采集成功后提交 Shape 和 `ObjectTopologySnapshot`。 +- 每个对象快照记录 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 校验均通过;提交与推送仍待执行。 + +这仍不是 OCCT/FreeCAD 原生历史:签名来自三角网格,缺少曲面/曲线参数、邻接图和 Boolean 的逐输入原生历史。诊断能定位引用所有者,但 3D 候选高亮与原子替换 UI 尚未实现,因此 F3/TSN-03/07/09 不能标记完成。 本里程碑不得声称 P3-03、F3 或完整 FreeCAD 兼容已经通过。当前对象 Placement 是 Shape 生成后的对象级变换,尚未证明 FreeCAD 对 Body/Tip、Support、AttachmentOffset 和局部坐标链的完整等价语义。 diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 14dd3e0..52ad077 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -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,并挂接到 Bitbybit `MeshAsset`。重复候选使用唯一候选 ID,一旧多新明确保持 ambiguous;面匹配可跨纯平移迁移。版本化 TopoRef JSON 只保存对象、种类、持久 ID、拓扑版本、generation、状态、签名和候选,显式拒绝 transient `faceIndex/edgeIndex/vertexIndex/subshapeIndex`。它仍没有 OCCT 历史 `Generated/Modified/Deleted` 信息、曲线/曲面参数签名、邻接图和跨特征布尔历史映射;TSN-03、TSN-07 与完整 TSN-08 仍是 FreeCAD 稳定子形状命名的必要任务。 +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 仍未完成。 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 仍是兼容门禁。 @@ -334,7 +334,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 | FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 | | FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型;未知类型为 proxy,Python 类型为 blocked;脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip | | P2-04 Project list/load | `project.list()` 暴露 SQLite/内存快照摘要,`app.document.load()` 将选定快照作为活动 Document 事务恢复,清理旧重算/Shape/Task 状态、保留 Undo 边界并按完整对象图异步重算;Projects 页面按 documentId 调用 Facade API | 列表摘要、保存→修改→加载后属性恢复、选择清空、任务清空和加载重算调用通过测试 | FCStd 导入仍是只读 metadata/proxy;跨文档 ShapeHandle 和 Shape 持久化仍待完成 | -| P2-01/P2-02 Migration/write pressure | schema v5 与统一事务 migration runner;失败 rollback;写队列严格串行并在失败后继续 | migration 排序/跳过/回滚测试及 1000 次队列压力通过 | SQLite v4→v5 的真实 OPFS 浏览器升级、崩溃注入与发布回滚演练仍待完成 | +| P2-01/P2-02 Migration/write pressure | schema v6 与统一事务 migration runner;v6 新增对象拓扑快照 JSON;失败 rollback;写队列严格串行并在失败后继续 | migration 排序/跳过/回滚测试、拓扑快照 fallback 往返及 1000 次队列压力通过 | SQLite v4→v5→v6 的真实 OPFS 浏览器升级、崩溃注入与发布回滚演练仍待完成 | | P2-03 Resource/recovery fallback | SQLite Worker 在无 OPFS 时使用内存资源表;OPFS 模式增加配额预留、重复内容免写、缺失文件重建和孤儿/零引用/缺失文件对账清扫;Facade 暴露 sweep 报告 | quota/sweep 纯策略、fallback API、Worker 类型检查和生产构建通过 | 真实 OPFS 孤儿清扫、QuotaExceeded、大文件压力及浏览器崩溃注入仍待完成 | | P2-04 Version checkpoints | 文档规范化保存与完整 JSON checkpoint 同事务;每文档保留最近 5 版;Recovery 列出版本,Facade 可加载指定/最新 checkpoint | 7 版保存后仅保留 3..7、指定版本/最新版加载、过期版本删除和深克隆测试通过 | 当前测试使用 transient fallback;SQLite/OPFS 断电点恢复和保留策略 UI 待完成 | | PART-07 Shape check | `check-shape` 使用 Facade `geometry.topology()` 生成面/边/顶点数量和 `SHAPE_CHECK_PASSED` 信息诊断;空缓存、空拓扑和 Worker 错误分别返回结构化失败诊断 | 无 Shape、错误选择前置和拓扑调用边界通过测试/构建 | 当前是网格派生拓扑检查,不等价于 OCCT `BRepCheck_Analyzer`,完整修复建议和自交诊断仍待实现 | @@ -342,8 +342,8 @@ 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,`App::PropertyLinkSub` 经 Facade 解析/校验和深克隆;依赖图产生带 persistentId 的 `topo-ref` 边;SQLite/内存 Project 以结构化 JSON 保存;Pocket 暴露 `UpToFace` 槽,Property 面板显示状态并可清除 | 非法瞬时索引/缺失对象拒绝、DAG 边、保存加载 round-trip、LinkSub 专项测试、桌面属性面板无横向溢出 | 尚未从 3D 选择创建引用,也未在重算后自动写回迁移结果;Pocket Up to face executor 仍保持结构化 unsupported,等待 OCCT 面历史/解析接线 | -| TSN-03/07 历史适配器回退 | Bitbybit 1.1.1 绑定仅有 `HasGenerated()`,没有逐 Shape 的 Generated/Modified/IsDeleted;新增显式 `signature-fallback` provider,把多输入到输出保守分类为 preserved/modified/generated/deleted/ambiguous,跨对象相同签名不抢占继承 | 平移修改、新增面、删除面和两个输入竞争同一输出的歧义测试通过;全套 50 项测试通过 | 此 provider 不是 OCCT history;只有自构建绑定暴露逐子形状历史并通过布尔黄金模型后,才能将 TSN-03/07 标为完成 | +| 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-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 标为完成 | 后续按以下顺序连续开发: diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index a133d71..f71a47a 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1594,7 +1594,7 @@ P0 基线/治理 | P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;新增 Part Design 特征会原子更新 Body.Tip,并按 Sketch/实体来源写入 Profile/Base;Feature 支持可持久化抑制、下游跳过、Shape 释放和解除后的最小闭包重算;文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、完整容器/Tip 重定向规则和原子崩溃恢复仍待实现 | | P4-06 诊断树与修复动作 | `PASS (基础闭环)` | 同步/异步重算错误生成带文档版本、generation、根因对象和依赖路径的结构化诊断;Facade 提供诊断树以及定位对象、合并当前 dirty 集后重算根因闭包、合法特征抑制动作;循环不会错误提供抑制修复;Diagnostics 页面通过 Facade 投影并执行动作;最近有效 Shape 显式回滚、复杂修复建议和性能计数器仍待实现 | | P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite;基础表达式/单位/Link DAG 已落地;`App::PropertyLinkSub` 使用版本化 TopoRef,生成 `topo-ref` DAG 边并可保存加载。3D 子形状拾取、重算迁移写回、locale 和多选 mixed 仍待实现 | -| P2-01/P2-02 SQLite schema 与 Persistence Worker | `PASS (领域/压力门禁)` | schema v5 新增 document checkpoints;统一 migration runner 在一个事务中排序、跳过已应用版本并在失败时 rollback;`PersistenceWriteQueue` 的 1000 次写入压力覆盖严格顺序、最大并发 1 和 8 次失败后的继续执行。实际 SQLite v4→v5 浏览器升级演练仍须进入 E2E/发布矩阵 | +| P2-01/P2-02 SQLite schema 与 Persistence Worker | `PASS (领域/压力门禁)` | schema v6 在 v5 checkpoints 基础上新增 `objects.topology_json`,保存 generation 拓扑快照;统一 migration runner 在一个事务中排序、跳过已应用版本并在失败时 rollback;`PersistenceWriteQueue` 的 1000 次写入压力覆盖严格顺序、最大并发 1 和 8 次失败后的继续执行。实际 SQLite v4→v5→v6 浏览器升级演练仍须进入 E2E/发布矩阵 | | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 提供 SHA-256 内容寻址、引用计数、读取/释放;新增 5% 配额预留、重复内容免重写、缺失文件重建,以及数据库记录/OPFS 文件对账清扫报告。配额与清扫策略单测和生产构建通过;真实 OPFS 孤儿文件、QuotaExceeded 和大资源浏览器压力仍待验证 | | Worker memory resource fallback | `PASS` | Worker 在 SQLite WASM 无 OPFS 时使用 transient content-addressed resource map,保持 `put/get/release` 引用计数语义;不再把资源 API 错误地绑定到 OPFS | | P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本;每次保存与规范化文档同事务写入完整 checkpoint,每文档裁剪为最近 5 版;Recovery 报告列出版本,`loadCheckpoint()` 可无损加载指定/最新版且返回深克隆。内存回放测试通过;SQLite/OPFS 崩溃注入和用户可配置保留策略仍待补齐 | @@ -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` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时;`geometry.topology()` 生成面/边/点签名;TopoRef schema v1 可序列化/解析/迁移并拒绝瞬时索引,重复拆分保持歧义、纯平移面可继承 ID。OCCT 历史命名、邻接/曲率签名和布尔映射仍未实现 | +| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;`geometry.topology()` 生成面/边/点签名条目,成功 generation 写入对象拓扑快照、迁移 matches 和保守历史;TopoRef schema v1 可序列化/解析/迁移并拒绝瞬时索引,重算提交自动迁移 LinkSub/外部几何,歧义/删除保持显式状态和诊断。OCCT 原生历史命名、邻接/曲率签名、跨 Boolean 映射和 3D 候选替换仍未实现 | | P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和浏览器矩阵已验证基础 Placement;Facade 重算 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/Revolution,Revolution 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 当前为整 Shape,Hole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 TopoRef、草图/面定位及沉孔/锪孔/螺纹仍待实现 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口优先使用重计算缓存的对象 Shape,失败时保留最近有效结果,无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 | -| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 60 个场景,包括 Part/PartDesign、Pattern/Hole、TopoRef/LinkSub、Sketch provider、几何重算、诊断修复、FCStd 安全、schema migration、1000 次写队列、检查点和资源治理;E2E/黄金几何待补齐 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 63 个场景,包括 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 构建验证。 diff --git a/src/facade/geometryRuntime.ts b/src/facade/geometryRuntime.ts index 444dc41..4cecdbd 100644 --- a/src/facade/geometryRuntime.ts +++ b/src/facade/geometryRuntime.ts @@ -448,7 +448,12 @@ export class BitbybitGeometryRuntime { const client = await this.readyClient() const mesh = await client.occt.shapeToMesh({ shape: entry.reference, precision, adjustYtoZ: false }) const faces = mesh.faceList.map((face) => ({ vertexCoord: face.vertexCoord, normalCoord: face.normalCoord, triIndexes: face.triIndexes })) - return { faces: createSubshapeRefs(shape.id, shape.documentVersion, faces, Math.max(1e-5, precision * 0.1)).refs, edges: createEdgeSubshapeRefs(shape.id, shape.documentVersion, faces, Math.max(1e-5, precision * 0.1)).refs, vertices: createVertexSubshapeRefs(shape.id, shape.documentVersion, faces, Math.max(1e-5, precision * 0.1)).refs } + const tolerance = Math.max(1e-5, precision * 0.1) + const faceTopology = createSubshapeRefs(shape.id, shape.documentVersion, faces, tolerance) + const edgeTopology = createEdgeSubshapeRefs(shape.id, shape.documentVersion, faces, tolerance) + const vertexTopology = createVertexSubshapeRefs(shape.id, shape.documentVersion, faces, tolerance) + const entries = [faceTopology, edgeTopology, vertexTopology].flatMap((topology) => topology.refs.map((ref, index) => ({ ref, signature: topology.signatures[index] }))) + return { faces: faceTopology.refs, edges: edgeTopology.refs, vertices: vertexTopology.refs, entries } } async release(shape: ShapeHandle): Promise { diff --git a/src/facade/index.ts b/src/facade/index.ts index cc751f1..0bfce03 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -7,10 +7,10 @@ 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, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot, TopoRefValue, 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, 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 { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences' -export type { PersistedTopoRef, TopoRefResolution, TopologyMigration } from './topologyReferences' +export { cloneObjectTopologySnapshot, createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences' +export type { DocumentTopologyReferenceMigration, PersistedTopoRef, TopologyMigration, TopologyReferenceMigrationIssue, TopoRefResolution } from './topologyReferences' export { captureSignatureTopologyHistory } from './topologyHistory' export type { TopologyHistoryEntry, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory' export { BasicSketchSolverAdapter, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, solveSketch } from './sketcher' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 8ba4ecf..87b8a8a 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -32,7 +32,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 { parseTopoRef } from './topologyReferences' +import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef } from './topologyReferences' const initialTree: ModelTreeItem[] = [ { id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] }, @@ -156,7 +156,7 @@ const clonePropertyValue = (value: PropertyValue): PropertyValue => { const cloneDocumentSnapshot = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), - objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), + objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined, }) @@ -538,6 +538,8 @@ export function createMockFacade(): BitBybitWebCadFacade { const document = cloneDocumentSnapshot(state.document) const updates = new Map(result.objectUpdates.map((object) => [object.id, object])) document.objects = document.objects.map((object) => updates.has(object.id) ? updates.get(object.id) as DocumentObjectSnapshot : object) + const topologyMigration = migrateDocumentTopologyReferences(document, result.objectUpdates.map((object) => object.id)) + document.dependencies = collectDependencyEdges(document) for (const objectId of result.affected) { if (result.objectStates[objectId] === 'suppressed' || result.objectStates[objectId] === 'upstream-suppressed') releaseFeatureShape(objectId) const item = document.tree.find((candidate) => candidate.id === objectId) @@ -555,8 +557,37 @@ export function createMockFacade(): BitBybitWebCadFacade { order: result.order, errors: result.errors, } + for (const objectId of topologyMigration.changedOwnerIds) { + document.recompute.objectStates[objectId] = 'touched' + if (!document.recompute.dirtyObjects.includes(objectId)) document.recompute.dirtyObjects.push(objectId) + const item = document.tree.find((candidate) => candidate.id === objectId) + if (item && item.state !== 'readonly') item.state = topologyMigration.issues.some((issue) => issue.ownerObjectId === objectId) ? 'warning' : 'dirty' + const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status') + 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 diagnostics = replaceRecomputeDiagnostics(state.diagnostics, document.id, result.affected, nextDiagnostics) + const topologyDiagnostics: Diagnostic[] = topologyMigration.issues.map((issue, index) => ({ + id: `topology:${document.id}:${result.generation}:${issue.ownerObjectId}:${issue.referenceName}:${index}`, + source: 'geometry', + severity: issue.status === 'deleted' ? 'error' : 'warning', + code: issue.status === 'deleted' ? 'TOPOLOGY_REFERENCE_DELETED' : 'TOPOLOGY_REFERENCE_AMBIGUOUS', + message: issue.status === 'deleted' + ? `${issue.referenceName} no longer resolves on ${issue.sourceObjectId}; select a replacement subshape.` + : `${issue.referenceName} resolves to multiple subshapes on ${issue.sourceObjectId}: ${(issue.candidates ?? []).join(', ')}.`, + objectId: issue.ownerObjectId, + documentId: document.id, + documentVersion: document.version, + generation: result.generation, + rootCauseObjectId: issue.sourceObjectId, + dependencyPath: [issue.ownerObjectId, issue.sourceObjectId], + 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] state = { ...state, document, diagnostics } const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId: `recompute-${result.generation}`, documentId: document.id, documentVersion: document.version, workbench: state.activeWorkbench } for (const diagnostic of nextDiagnostics) emit({ type: 'diagnostic.added', diagnostic, context }) diff --git a/src/facade/persistenceWorker.ts b/src/facade/persistenceWorker.ts index 3a81169..72aedda 100644 --- a/src/facade/persistenceWorker.ts +++ b/src/facade/persistenceWorker.ts @@ -89,7 +89,7 @@ const saveDocument = (document: DocumentSnapshot) => { database.exec({ sql: 'DELETE FROM objects WHERE document_id = ?', bind: [document.id] }) const parentByChild = new Map() for (const item of document.tree) for (const childId of item.children || []) parentByChild.set(childId, item.id) - document.tree.forEach((item, ordinal) => { const object = document.objects.find((candidate) => candidate.id === item.id); database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal, sketch_json) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', bind: [item.id, document.id, parentByChild.get(item.id) || null, item.label, item.type, item.state || null, item.detail || null, JSON.stringify(item.children || []), ordinal, object?.sketch ? JSON.stringify(object.sketch) : null] }) }) + document.tree.forEach((item, ordinal) => { const object = document.objects.find((candidate) => candidate.id === item.id); database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal, sketch_json, topology_json) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', bind: [item.id, document.id, parentByChild.get(item.id) || null, item.label, item.type, item.state || null, item.detail || null, JSON.stringify(item.children || []), ordinal, object?.sketch ? JSON.stringify(object.sketch) : null, object?.topology ? JSON.stringify(object.topology) : null] }) }) for (const object of document.objects) for (const property of object.properties) database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, object.id, property.name, JSON.stringify(property), property.type, now] }) database.exec({ sql: 'DELETE FROM dependencies WHERE document_id = ?', bind: [document.id] }) for (const edge of document.dependencies ?? []) database.exec({ sql: 'INSERT INTO dependencies(document_id, source_id, target_id, relation, property_name, reference) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, edge.sourceId, edge.targetId, edge.relation, edge.propertyName ?? null, edge.reference ?? null] }) @@ -123,7 +123,7 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => { const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const row = documents[0] if (!row) return null - const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json, sketch_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> + const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json, sketch_json, topology_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const propertyRows = database.exec({ sql: 'SELECT object_id, value_json FROM object_properties WHERE document_id = ? ORDER BY object_id, name', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const dependencyRows = database.exec({ sql: 'SELECT source_id, target_id, relation, property_name, reference FROM dependencies WHERE document_id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const propertiesByObject = new Map() @@ -137,7 +137,7 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => { const properties = propertiesByObject.get(item.id) ?? [] const typeId = properties.find((property) => property.name === 'TypeId')?.value const row = objects.find((candidate) => String(candidate.id) === item.id) - return { id: item.id, typeId: typeof typeId === 'string' ? typeId : item.type, properties, sketch: row?.sketch_json ? JSON.parse(String(row.sketch_json)) : undefined } + return { id: item.id, typeId: typeof typeId === 'string' ? typeId : item.type, properties, sketch: row?.sketch_json ? JSON.parse(String(row.sketch_json)) : undefined, topology: row?.topology_json ? JSON.parse(String(row.topology_json)) : undefined } }) return { id: String(row.id), diff --git a/src/facade/projectSchema.ts b/src/facade/projectSchema.ts index f56e935..9c4e11c 100644 --- a/src/facade/projectSchema.ts +++ b/src/facade/projectSchema.ts @@ -1,4 +1,4 @@ -export const PROJECT_SCHEMA_VERSION = 5 +export const PROJECT_SCHEMA_VERSION = 6 export type ProjectSchemaMigration = { version: number; sql: string } @@ -140,4 +140,5 @@ export const PROJECT_SCHEMA_MIGRATIONS = [ { version: 3, sql: 'ALTER TABLE dependencies ADD COLUMN property_name TEXT; ALTER TABLE dependencies ADD COLUMN reference TEXT;' }, { version: 4, sql: 'ALTER TABLE objects ADD COLUMN sketch_json TEXT;' }, { version: 5, sql: 'CREATE TABLE IF NOT EXISTS document_checkpoints (document_id TEXT NOT NULL, version INTEGER NOT NULL, snapshot_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (document_id, version), FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS checkpoints_document_created ON document_checkpoints(document_id, created_at DESC);' }, + { version: 6, sql: 'ALTER TABLE objects ADD COLUMN topology_json TEXT;' }, ] as const diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index 41db8b8..f3b6057 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -1,5 +1,6 @@ import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types' import { cloneSketch } from './sketcher' +import { cloneObjectTopologySnapshot } from './topologyReferences' type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' | 'sweep-resources' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'load-checkpoint'; documentId: string; version?: number } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string } type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' | 'sweep-resources' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'load-checkpoint'; documentId: string; version?: number } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string } @@ -13,7 +14,7 @@ const clonePropertyValue = (value: PropertyValue): PropertyValue => { if ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined } return { ...value } } -const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined }) +const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined }) export interface ProjectPersistenceClient { initialize(): Promise diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts index 21f2feb..969eebc 100644 --- a/src/facade/recomputeEngine.ts +++ b/src/facade/recomputeEngine.ts @@ -1,6 +1,8 @@ import { DependencyGraph, type RecomputeState } from './dependencyGraph' import { cloneSketch, solveSketch } from './sketcher' -import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types' +import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, ObjectTopologySnapshot, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle, SubshapeTopology } from './types' +import { captureSignatureTopologyHistory } from './topologyHistory' +import { migrateTopoRefs } from './topologyReferences' export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale' @@ -15,6 +17,7 @@ export type RecomputeNodeContext = { documentVersion: number generation: number signal: AbortSignal + isCurrent?: () => boolean } export type RecomputeNodeResult = { @@ -44,6 +47,7 @@ export type RecomputeGeometryRuntime = { revolution(input: RevolutionInput): Promise fillet(input: FilletInput): Promise chamfer(input: ChamferInput): Promise + topology?(shape: ShapeHandle, precision?: number): Promise release(shape: ShapeHandle): Promise } @@ -174,6 +178,7 @@ export class RecomputeCoordinator { documentVersion: document.version, generation, signal: controller.signal, + isCurrent: () => this.active?.generation === generation && this.currentDocumentVersion(document.id) === document.version, }) if (result.status === 'failed') { return { @@ -293,6 +298,43 @@ const placementForObject = (object: DocumentObjectSnapshot) => { rotationAngle: angle, } } + +const topologyForObject = async ( + geometry: RecomputeGeometryRuntime, + object: DocumentObjectSnapshot, + shape: ShapeHandle, + context: RecomputeNodeContext, +): Promise => { + if (!geometry.topology) return undefined + const topology = await geometry.topology(shape, 0.05) + const previous = object.topology?.entries ?? [] + const migration = migrateTopoRefs(object.id, previous, topology.entries, context.generation) + const entries = topology.entries.map((entry, index) => ({ + ref: { + ...migration.matches[index].current, + shapeId: shape.id, + topologyVersion: context.documentVersion, + candidates: migration.matches[index].current.candidates ? [...migration.matches[index].current.candidates as string[]] : undefined, + }, + signature: { ...entry.signature, centroid: [...entry.signature.centroid], bounds: { min: [...entry.signature.bounds.min], max: [...entry.signature.bounds.max] }, normal: [...entry.signature.normal] }, + })) as ObjectTopologySnapshot['entries'] + const history = captureSignatureTopologyHistory( + `${object.id}:generation:${context.generation}`, + previous.length > 0 ? [{ objectId: object.id, entries: previous }] : [], + entries, + ) + return { + shapeId: shape.id, + documentVersion: context.documentVersion, + generation: context.generation, + entries, + migration: { + previousGeneration: object.topology?.generation ?? null, + matches: migration.matches.map((match) => ({ ...match, current: { ...match.current, candidates: match.current.candidates ? [...match.current.candidates] : undefined } })), + }, + history, + } +} const linkedObject = (object: DocumentObjectSnapshot, name: string, document: DocumentSnapshot) => { const value = propertyValue(object, name) return typeof value === 'string' ? document.objects.find((candidate) => candidate.id === value) : undefined @@ -471,14 +513,21 @@ export const createFacadeGeometryRecomputeExecutor = ( throw error } } - if (context.signal.aborted) { + let topology: ObjectTopologySnapshot | undefined + try { + topology = await topologyForObject(geometry, object, result, context) + } catch (error) { + await Promise.allSettled([geometry.release(result)]) + throw error + } + if (context.signal.aborted || context.isCurrent?.() === false) { await geometry.release(result) - throw new DOMException('Recompute cancelled.', 'AbortError') + throw new DOMException('Recompute result is no longer current.', 'AbortError') } const previous = shapes.get(object.id) shapes.set(object.id, result) if (previous && previous.id !== result.id) await geometry.release(previous) - return base + return topology ? { ...base, updatedObject: { ...(base.updatedObject ?? object), topology } } : base } catch (error) { if (context.signal.aborted || isAbortError(error)) throw error return geometryFailure(object.id, 'GEOMETRY_EXECUTION_FAILED', error instanceof Error ? error.message : String(error)) diff --git a/src/facade/topologyHistory.ts b/src/facade/topologyHistory.ts index 1e1cc01..c5d72bc 100644 --- a/src/facade/topologyHistory.ts +++ b/src/facade/topologyHistory.ts @@ -1,23 +1,10 @@ -import type { SubshapeRef } from './types' +import type { SubshapeRef, TopologyHistoryRelation as StoredTopologyHistoryRelation, TopologyHistoryResult as StoredTopologyHistoryResult } from './types' import { matchSubshapes, type SubshapeSignature } from './topologyNaming' export type TopologyHistoryEntry = { ref: SubshapeRef; signature: SubshapeSignature } -export type TopologyHistoryRelation = { - relation: 'preserved' | 'modified' | 'generated' | 'deleted' | 'ambiguous' - sourceObjectId?: string - sourcePersistentId?: string - resultPersistentId?: string - candidates?: Array<{ sourceObjectId: string; persistentId: string }> - score: number -} - -export type TopologyHistoryResult = { - operationId: string - provider: 'signature-fallback' - relations: TopologyHistoryRelation[] - counts: Record -} +export type TopologyHistoryRelation = StoredTopologyHistoryRelation +export type TopologyHistoryResult = StoredTopologyHistoryResult const sourceKey = (objectId: string, persistentId: string) => `${encodeURIComponent(objectId)}::${persistentId}` diff --git a/src/facade/topologyNaming.ts b/src/facade/topologyNaming.ts index 098207c..d3283ff 100644 --- a/src/facade/topologyNaming.ts +++ b/src/facade/topologyNaming.ts @@ -1,4 +1,6 @@ -import type { SubshapeRef } from './types' +import type { SubshapeRef, SubshapeSignature } from './types' + +export type { SubshapeSignature } from './types' export type FaceMeshInput = { vertexCoord: number[] @@ -6,16 +8,6 @@ export type FaceMeshInput = { triIndexes: number[] } -export type SubshapeSignature = { - kind: 'face' | 'edge' | 'vertex' - canonical: string - hash: string - centroid: [number, number, number] - bounds: { min: [number, number, number]; max: [number, number, number] } - area: number - normal: [number, number, number] -} - export type SubshapeMatch = { current: SubshapeRef previousId?: string @@ -172,7 +164,10 @@ export const matchSubshapes = (previous: Array<{ ref: SubshapeRef; signature: Su } else if (best && best.score > 0.5 && second && Math.abs(best.score - second.score) <= 0.05) { contested.add(best.entry.ref.persistentId); contested.add(second.entry.ref.persistentId) matches.push({ current: { ...candidate.ref, status: 'ambiguous', candidates: [best.entry.ref.persistentId, second.entry.ref.persistentId] }, score: best.score, status: 'ambiguous' }) - } else matches.push({ current: { ...candidate.ref, status: 'new' }, score: best?.score ?? 0, status: 'new' }) + } else { + const status = candidate.ref.status === 'ambiguous' ? 'ambiguous' as const : 'new' as const + matches.push({ current: { ...candidate.ref, status }, score: best?.score ?? 0, status }) + } } for (const entry of previous) if (!used.has(entry.ref.persistentId) && !contested.has(entry.ref.persistentId)) matches.push({ current: { ...entry.ref, status: 'deleted' }, previousId: entry.ref.persistentId, score: 0, status: 'deleted' }) return matches diff --git a/src/facade/topologyReferences.ts b/src/facade/topologyReferences.ts index 1a45f5b..ffa9353 100644 --- a/src/facade/topologyReferences.ts +++ b/src/facade/topologyReferences.ts @@ -1,4 +1,4 @@ -import type { SubshapeRef, TopoRefValue } from './types' +import type { DocumentSnapshot, ObjectTopologySnapshot, SubshapeRef, TopoRefValue } from './types' import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming' export type PersistedTopoRef = TopoRefValue @@ -15,6 +15,37 @@ export type TopologyMigration = { counts: Record, number> } +export type TopologyReferenceMigrationIssue = { + ownerObjectId: string + sourceObjectId: string + referenceName: string + status: 'ambiguous' | 'deleted' + persistentId: string + candidates?: string[] +} + +export type DocumentTopologyReferenceMigration = { + changedOwnerIds: string[] + issues: TopologyReferenceMigrationIssue[] +} + +export const cloneObjectTopologySnapshot = (snapshot: ObjectTopologySnapshot): ObjectTopologySnapshot => ({ + ...snapshot, + entries: snapshot.entries.map((entry) => ({ + ref: { ...entry.ref, candidates: entry.ref.candidates ? [...entry.ref.candidates] : undefined }, + signature: { ...entry.signature, centroid: [...entry.signature.centroid], bounds: { min: [...entry.signature.bounds.min], max: [...entry.signature.bounds.max] }, normal: [...entry.signature.normal] }, + })), + migration: { + ...snapshot.migration, + matches: snapshot.migration.matches.map((match) => ({ ...match, current: { ...match.current, candidates: match.current.candidates ? [...match.current.candidates] : undefined } })), + }, + history: { + ...snapshot.history, + counts: { ...snapshot.history.counts }, + relations: snapshot.history.relations.map((relation) => relation.candidates ? { ...relation, candidates: relation.candidates.map((candidate) => ({ ...candidate })) } : { ...relation }), + }, +}) + const transientIndexKeys = new Set(['faceIndex', 'edgeIndex', 'vertexIndex', 'subshapeIndex']) const statuses = new Set>(['stable', 'ambiguous', 'new', 'deleted']) const kinds = new Set(['face', 'edge', 'vertex']) @@ -98,3 +129,52 @@ export const migrateTopoRefs = ( }) return { records, matches, counts } } + +const migrateReferenceThroughSnapshot = (record: PersistedTopoRef, snapshot: ObjectTopologySnapshot): PersistedTopoRef => { + if (record.objectId.trim() === '' || record.generation >= snapshot.generation) return { ...record, candidates: record.candidates ? [...record.candidates] : undefined } + if (record.status === 'deleted') return { ...record, topologyVersion: snapshot.documentVersion, generation: snapshot.generation, candidates: undefined } + const stable = snapshot.migration.matches.find((match) => match.status === 'stable' && match.previousId === record.persistentId) + if (stable) return createPersistedTopoRef(record.objectId, stable.current, snapshot.generation) + const ambiguous = snapshot.migration.matches.filter((match) => match.status === 'ambiguous' && match.current.candidates?.includes(record.persistentId)) + if (ambiguous.length > 0) return { + ...record, + topologyVersion: snapshot.documentVersion, + generation: snapshot.generation, + status: 'ambiguous', + candidates: [...new Set(ambiguous.map((match) => match.current.persistentId))], + } + const deleted = snapshot.migration.matches.some((match) => match.status === 'deleted' && match.previousId === record.persistentId) + if (deleted) return { ...record, topologyVersion: snapshot.documentVersion, generation: snapshot.generation, status: 'deleted', candidates: undefined } + const resolution = resolveTopoRef(record, snapshot.entries.map((entry) => entry.ref)) + if (resolution.status === 'resolved' && resolution.ref) return createPersistedTopoRef(record.objectId, resolution.ref, snapshot.generation) + if (resolution.status === 'ambiguous') return { ...record, topologyVersion: snapshot.documentVersion, generation: snapshot.generation, status: 'ambiguous', candidates: resolution.candidates?.map((candidate) => candidate.persistentId) } + return { ...record, topologyVersion: snapshot.documentVersion, generation: snapshot.generation, status: 'deleted', candidates: undefined } +} + +export const migrateDocumentTopologyReferences = ( + document: DocumentSnapshot, + sourceObjectIds: Iterable, +): DocumentTopologyReferenceMigration => { + const sources = new Map([...new Set(sourceObjectIds)].map((objectId) => { + const source = document.objects.find((object) => object.id === objectId) + return source?.topology ? [objectId, source.topology] as const : null + }).filter((entry): entry is readonly [string, ObjectTopologySnapshot] => Boolean(entry))) + const changedOwnerIds = new Set() + const issues: TopologyReferenceMigrationIssue[] = [] + const migrate = (ownerObjectId: string, referenceName: string, record: PersistedTopoRef) => { + const source = sources.get(record.objectId) + 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 }) + return migrated + } + for (const object of document.objects) { + for (const property of object.properties) { + if (property.type !== 'App::PropertyLinkSub' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('schemaVersion' in property.value)) continue + property.value = migrate(object.id, property.name, property.value) + } + for (const external of object.sketch?.externalGeometry ?? []) external.source = migrate(object.id, `ExternalGeometry:${external.id}`, external.source) + } + return { changedOwnerIds: [...changedOwnerIds], issues } +} diff --git a/src/facade/types.ts b/src/facade/types.ts index e32c3d2..36cdcc7 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -56,6 +56,7 @@ export type DocumentObjectSnapshot = { typeId: string properties: ObjectPropertySnapshot[] sketch?: SketchSnapshot + topology?: ObjectTopologySnapshot } export type DocumentSnapshot = { @@ -177,10 +178,55 @@ export type SubshapeRef = { candidates?: string[] } +export type SubshapeSignature = { + kind: SubshapeRef['kind'] + canonical: string + hash: string + centroid: [number, number, number] + bounds: { min: [number, number, number]; max: [number, number, number] } + area: number + normal: [number, number, number] +} + +export type TopologySnapshotEntry = { ref: SubshapeRef; signature: SubshapeSignature } + +export type TopologyMigrationMatch = { + current: SubshapeRef + previousId?: string + score: number + status: 'stable' | 'ambiguous' | 'new' | 'deleted' +} + +export type TopologyHistoryRelation = { + relation: 'preserved' | 'modified' | 'generated' | 'deleted' | 'ambiguous' + sourceObjectId?: string + sourcePersistentId?: string + resultPersistentId?: string + candidates?: Array<{ sourceObjectId: string; persistentId: string }> + score: number +} + +export type TopologyHistoryResult = { + operationId: string + provider: 'signature-fallback' + relations: TopologyHistoryRelation[] + counts: Record +} + +export type ObjectTopologySnapshot = { + shapeId: string + documentVersion: number + generation: number + entries: TopologySnapshotEntry[] + migration: { previousGeneration: number | null; matches: TopologyMigrationMatch[] } + history: TopologyHistoryResult +} + export type SubshapeTopology = { faces: SubshapeRef[] edges: SubshapeRef[] vertices: SubshapeRef[] + entries: TopologySnapshotEntry[] } export type MeshAsset = { diff --git a/tests/facade.test.ts b/tests/facade.test.ts index c824584..1667865 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -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, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences' +import { createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, 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' @@ -15,7 +15,7 @@ import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolver import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine' import { inspectFcstdArchive } from '../src/facade/fcstd' import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' -import type { DocumentSnapshot, ShapeHandle } from '../src/facade/types' +import type { DocumentObjectSnapshot, DocumentSnapshot, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types' const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({ id: 'doc-recompute', @@ -110,8 +110,8 @@ test('Bitbybit face meshes are normalized into a facade-owned indexed asset', () }) test('project schema is versioned and covers the FreeCAD document graph', () => { - assert.equal(PROJECT_SCHEMA_VERSION, 5) - assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5]) + assert.equal(PROJECT_SCHEMA_VERSION, 6) + assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5, 6]) for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources', 'document_checkpoints']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)) assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/) }) @@ -781,6 +781,141 @@ test('object Placement transforms recomputed shapes before committing the cache' await persistence.dispose() }) +test('geometry recompute captures generation topology history and isolates capture failures', async () => { + const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] } + const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) } + let currentFace = square + let topologyShouldFail = false + const releases: string[] = [] + const shape = (id: string, documentVersion: number): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-topology-generation', documentVersion }) + const runtime: RecomputeGeometryRuntime = { + capabilities: () => ({ status: 'ready' }), + createBox: async (input) => shape(`box-${input.documentVersion}`, input.documentVersion), + createCylinder: async (input) => shape('cylinder', input.documentVersion), + createSphere: async (input) => shape('sphere', input.documentVersion), + createCone: async (input) => shape('cone', input.documentVersion), + applyPlacement: async (input) => input.shape, + union: async (input) => shape('union', input.documentVersion), + cut: async (input) => shape('cut', input.documentVersion), + intersection: async (input) => shape('intersection', input.documentVersion), + pad: async (input) => shape('pad', input.documentVersion), + pocket: async (input) => shape('pocket', input.documentVersion), + revolution: async (input) => shape('revolution', input.documentVersion), + fillet: async (input) => shape('fillet', input.documentVersion), + chamfer: async (input) => shape('chamfer', input.documentVersion), + topology: async (handle): Promise => { + if (topologyShouldFail) throw new Error('topology capture failed') + const created = createSubshapeRefs(handle.id, handle.documentVersion, [currentFace]) + return { faces: created.refs, edges: [], vertices: [], entries: created.refs.map((ref, index) => ({ ref, signature: created.signatures[index] })) } + }, + release: async (released) => { releases.push(released.id) }, + } + const object: DocumentObjectSnapshot = { id: 'box', typeId: 'Part::Box', properties: [] } + const shapes = new Map() + const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes) + const firstDocument: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-topology-generation', version: 1, tree: [{ id: 'box', label: 'Box', type: 'feature' }], objects: [object], dependencies: [] } + const first = await executor(object, firstDocument, { documentId: firstDocument.id, documentVersion: 1, generation: 1, signal: new AbortController().signal }) + assert.equal(first.status, 'success') + assert.equal(first.updatedObject?.topology?.generation, 1) + assert.equal(first.updatedObject?.topology?.history.counts.generated, 1) + assert.equal(first.updatedObject?.topology?.entries[0].ref.status, 'new') + + currentFace = translated + const secondObject = first.updatedObject as DocumentObjectSnapshot + const secondDocument = { ...firstDocument, version: 2, objects: [secondObject] } + const second = await executor(secondObject, secondDocument, { documentId: secondDocument.id, documentVersion: 2, generation: 2, signal: new AbortController().signal }) + assert.equal(second.status, 'success') + assert.equal(second.updatedObject?.topology?.generation, 2) + assert.equal(second.updatedObject?.topology?.migration.previousGeneration, 1) + assert.equal(second.updatedObject?.topology?.history.counts.modified, 1) + assert.equal(second.updatedObject?.topology?.entries[0].ref.persistentId, first.updatedObject?.topology?.entries[0].ref.persistentId) + assert.equal(shapes.get('box')?.id, 'box-2') + + const persistence = createSqliteProjectPersistence() + await persistence.save({ ...secondDocument, objects: [second.updatedObject as DocumentObjectSnapshot] }) + assert.deepEqual((await persistence.load(secondDocument.id))?.objects[0].topology, second.updatedObject?.topology) + await persistence.dispose() + + const staleObject = second.updatedObject as DocumentObjectSnapshot + const staleDocument = { ...secondDocument, version: 3, objects: [staleObject] } + await assert.rejects(() => executor(staleObject, staleDocument, { documentId: staleDocument.id, documentVersion: 3, generation: 3, signal: new AbortController().signal, isCurrent: () => false }), /no longer current/) + assert.equal(shapes.get('box')?.id, 'box-2') + assert.ok(releases.includes('box-3')) + + topologyShouldFail = true + const thirdObject = second.updatedObject as DocumentObjectSnapshot + const thirdDocument = { ...secondDocument, version: 4, objects: [thirdObject] } + const failed = await executor(thirdObject, thirdDocument, { documentId: thirdDocument.id, documentVersion: 4, generation: 4, signal: new AbortController().signal }) + assert.equal(failed.status, 'failed') + assert.match(failed.errors?.[0].message ?? '', /topology capture failed/) + assert.equal(shapes.get('box')?.id, 'box-2') + assert.ok(releases.includes('box-4')) +}) + +test('generation topology migration updates LinkSub and external geometry without forcing ambiguous or deleted refs', () => { + const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] } + const previous = createSubshapeRefs('shape-old', 1, [face]) + const current = createSubshapeRefs('shape-new', 2, [face, face]) + const previousEntries = previous.refs.map((ref, index) => ({ ref, signature: previous.signatures[index] })) + const currentEntries = current.refs.map((ref, index) => ({ ref, signature: current.signatures[index] })) + const initialDuplicate = migrateTopoRefs('source', [], currentEntries, 1) + assert.equal(initialDuplicate.counts.ambiguous, 2) + const migration = migrateTopoRefs('source', previousEntries, currentEntries, 2) + const topology: ObjectTopologySnapshot = { + shapeId: 'shape-new', documentVersion: 2, generation: 2, + entries: currentEntries, + migration: { previousGeneration: 1, matches: migration.matches }, + history: captureSignatureTopologyHistory('source:generation:2', [{ objectId: 'source', entries: previousEntries }], currentEntries), + } + const reference = createPersistedTopoRef('source', previous.refs[0], 1) + const sketch = createSketch('owner') + sketch.externalGeometry.push({ id: 'external', source: { ...reference }, projection: { id: 'projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }, construction: true }) + const document: DocumentSnapshot = { + ...recomputeDocumentFixture(), id: 'doc-topology-migration', version: 2, + tree: [{ id: 'source', label: 'Source', type: 'feature' }, { id: 'owner', label: 'Owner', type: 'sketch' }], + objects: [ + { id: 'source', typeId: 'Part::Box', properties: [], topology }, + { id: 'owner', typeId: 'Sketcher::SketchObject', properties: [{ name: 'SupportFace', label: 'Support face', group: 'Attachment', scope: 'data', type: 'App::PropertyLinkSub', value: { ...reference } }], sketch }, + ], dependencies: [], + } + const ambiguous = migrateDocumentTopologyReferences(document, ['source']) + const property = document.objects[1].properties[0].value + const external = document.objects[1].sketch?.externalGeometry[0].source + assert.equal(property && typeof property === 'object' && !Array.isArray(property) && 'status' in property ? property.status : null, 'ambiguous') + assert.equal(external?.status, 'ambiguous') + assert.equal(external?.generation, 2) + assert.equal(external?.candidates?.length, 2) + assert.deepEqual(ambiguous.changedOwnerIds, ['owner']) + assert.deepEqual(ambiguous.issues.map((issue) => issue.referenceName), ['SupportFace', 'ExternalGeometry:external']) + + const deletedMigration = migrateTopoRefs('source', previousEntries, [], 3) + document.objects[0].topology = { + shapeId: 'shape-empty', documentVersion: 3, generation: 3, entries: [], + migration: { previousGeneration: 1, matches: deletedMigration.matches }, + history: captureSignatureTopologyHistory('source:generation:3', [{ objectId: 'source', entries: previousEntries }], []), + } + document.objects[1].properties[0].value = { ...reference } + if (document.objects[1].sketch) document.objects[1].sketch.externalGeometry[0].source = { ...reference } + const deleted = migrateDocumentTopologyReferences(document, ['source']) + assert.equal((document.objects[1].properties[0].value as typeof reference).status, 'deleted') + assert.equal(document.objects[1].sketch?.externalGeometry[0].source.status, 'deleted') + assert.ok(deleted.issues.every((issue) => issue.status === 'deleted')) + + const currentSingle = createSubshapeRefs('shape-later', 4, [face]) + const currentSingleEntries = currentSingle.refs.map((ref, index) => ({ ref: { ...ref, persistentId: reference.persistentId }, signature: currentSingle.signatures[index] })) + const skippedGeneration = migrateTopoRefs('source', [], currentSingleEntries, 4) + document.objects[0].topology = { + shapeId: 'shape-later', documentVersion: 4, generation: 4, entries: currentSingleEntries, + migration: { previousGeneration: 3, matches: skippedGeneration.matches }, + history: captureSignatureTopologyHistory('source:generation:4', [], currentSingleEntries), + } + document.objects[1].properties[0].value = { ...reference } + const resolvedAcrossGap = migrateDocumentTopologyReferences(document, ['source']) + assert.equal((document.objects[1].properties[0].value as typeof reference).status, 'stable') + assert.equal((document.objects[1].properties[0].value as typeof reference).generation, 4) + assert.equal(resolvedAcrossGap.issues.length, 1) +}) + test('facade async recompute commits only an accepted generation', async () => { const facade = createMockFacade() facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })