feat: persist generation topology history
This commit is contained in:
@@ -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"] }
|
||||
},
|
||||
|
||||
@@ -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 和局部坐标链的完整等价语义。
|
||||
|
||||
@@ -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 标为完成 |
|
||||
|
||||
后续按以下顺序连续开发:
|
||||
|
||||
|
||||
@@ -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 构建验证。
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -89,7 +89,7 @@ const saveDocument = (document: DocumentSnapshot) => {
|
||||
database.exec({ sql: 'DELETE FROM objects WHERE document_id = ?', bind: [document.id] })
|
||||
const parentByChild = new Map<string, string>()
|
||||
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<Record<string, string | number | null>>
|
||||
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<Record<string, string | number | null>>
|
||||
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<Record<string, string | number | null>>
|
||||
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<Record<string, string>>
|
||||
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<Record<string, string | null>>
|
||||
const propertiesByObject = new Map<string, ObjectPropertySnapshot[]>()
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PersistenceCapabilities>
|
||||
|
||||
@@ -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<ShapeHandle>
|
||||
fillet(input: FilletInput): Promise<ShapeHandle>
|
||||
chamfer(input: ChamferInput): Promise<ShapeHandle>
|
||||
topology?(shape: ShapeHandle, precision?: number): Promise<SubshapeTopology>
|
||||
release(shape: ShapeHandle): Promise<void>
|
||||
}
|
||||
|
||||
@@ -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<ObjectTopologySnapshot | undefined> => {
|
||||
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))
|
||||
|
||||
@@ -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<TopologyHistoryRelation['relation'], number>
|
||||
}
|
||||
export type TopologyHistoryRelation = StoredTopologyHistoryRelation
|
||||
export type TopologyHistoryResult = StoredTopologyHistoryResult
|
||||
|
||||
const sourceKey = (objectId: string, persistentId: string) => `${encodeURIComponent(objectId)}::${persistentId}`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<NonNullable<SubshapeRef['status']>, 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<NonNullable<SubshapeRef['status']>>(['stable', 'ambiguous', 'new', 'deleted'])
|
||||
const kinds = new Set<SubshapeRef['kind']>(['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<string>,
|
||||
): 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<string>()
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<TopologyHistoryRelation['relation'], number>
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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<SubshapeTopology> => {
|
||||
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<string, ShapeHandle>()
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user