diff --git a/config/chrome-opfs-migration-verification.json b/config/chrome-opfs-migration-verification.json index 90505e5..f61b098 100644 --- a/config/chrome-opfs-migration-verification.json +++ b/config/chrome-opfs-migration-verification.json @@ -5,13 +5,16 @@ "status": "pass", "migration": { "sourceSchemaVersion": 1, - "migratedSchemaVersion": 6, + "migratedSchemaVersion": 7, "migratedDocumentVersion": 3, "migratedLabel": "Legacy OPFS project", "migratedObjectCount": 1, + "migratedNativeObjectTag": 1, "recomputeDefaulted": true, - "reopenedSchemaVersion": 6, + "reopenedSchemaVersion": 7, "reopenedVersion": 4, + "reopenedNativeObjectTag": 17, + "checkpointNativeObjectTag": 17, "reopenedLabel": "Migrated OPFS project", "checkpointVersion": 4, "recoveryIntegrity": "ok", @@ -28,9 +31,9 @@ ] }, "cleanup": { - "databasePath": "/bitbybit-rel02-301b6b76-9ab6-44ef-bf40-f2e6052e58ae.sqlite3", + "databasePath": "/bitbybit-rel02-976dea78-76e4-4a2b-afce-c0a2da583550.sqlite3", "removedFiles": [ - "bitbybit-rel02-301b6b76-9ab6-44ef-bf40-f2e6052e58ae.sqlite3" + "bitbybit-rel02-976dea78-76e4-4a2b-afce-c0a2da583550.sqlite3" ] } } diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index a7b9621..5f3f903 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -100,6 +100,18 @@ "exactBlockers": ["shipped Worker lacks the FreeCAD-linked private naming ABI implementation", "browser builder naming evidence transport", "non-unique isomorphic source"] } }, + "freecadNamingBuild": { + "status": "contract-only", + "sdkEnvironment": "FREECAD_WASM_SDK_DIR", + "requiredManifest": "manifest.json", + "requiredCallbacks": ["freecadNamingAbiVersion", "freecadNamingCapabilitiesJson", "freecadNamingEvidenceJson"], + "requiredFreecadVersion": "1.1.1", + "requiredSourceCommit": "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d", + "requiredEmscriptenVersion": "3.1.69", + "buildCommand": "./npmw run build:freecad-naming-worker", + "checkCommand": "./npmw run check:freecad-naming-sdk", + "statusBoundary": "Missing SDK or callback probe keeps EX-TSN-02=in_progress and systemExact=false" + }, "browserWorker": { "entry": "src/facade/nativeHistoryWorkerEntry.ts", "client": "src/facade/nativeHistoryWorkerClient.ts", @@ -117,7 +129,7 @@ "facadeCapabilities": { "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "object-placement-recompute", "placement-validation", "mirror-plane", "mirrored-whole-shape", "multi-transform-whole-shape", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "loft", "pipe", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "native-cross-feature-history-routing", "step-export", "stl-export"], "featureLevels": { "pad": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "pocket": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "revolution": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "groove": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it", "OCCT/FreeCAD private token generation"] }, "boolean": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it", "isomorphic source ambiguity"] }, "fillet": { "level": "experimental", "exactBlockedBy": ["complete cross-feature native stage history"] }, "chamfer": { "level": "experimental", "exactBlockedBy": ["complete cross-feature native stage history"] }, "multi-transform": { "level": "experimental", "exactBlockedBy": ["feature-list native history", "private naming tokens"] } } }, "document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-multi-transform", "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", "adjacency-fingerprint-disambiguation", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "native-topology-history-adapter", "native-cross-feature-history-routing", "runtime-mapped-name-ref-evidence", "string-hasher-evidence-validation", "element-map2-multi-stage-name-mapping", "isomorphic-candidate-persistence", "element-map-name-contract", "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"] }, + "persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v7", "stable-native-object-tags", "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; FreeCAD 1.1.1 planegcs WASM core installed", "operations": ["point-line-circle-arc-model", "ellipse-bspline-periodic-model", "construction-geometry", "geometry-validation", "stable-geometry-signature", "stable-external-geometry-model", "basic-constraints", "advanced-constraint-proxy", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "planegcs-native-system-solve", "planegcs-horizontal-distance", "planegcs-vertical-distance", "planegcs-distance-x", "planegcs-distance-y", "planegcs-angle", "planegcs-circle-radius", "planegcs-circle-diameter", "planegcs-equal-lines", "planegcs-equal-circles", "planegcs-tangent-circles", "planegcs-point-symmetry", "planegcs-point-on-object", "planegcs-point-on-arc", "planegcs-point-on-ellipse", "planegcs-point-on-cubic-bspline", "planegcs-block-freeze", "planegcs-parallel-lines", "planegcs-perpendicular-lines", "planegcs-coincident-lines", "planegcs-snells-law-lines", "planegcs-ellipse-internal-alignment", "planegcs-cubic-bspline-weight", "planegcs-dedicated-worker-smoke", "persistence"] }, "fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "document-property-projection", "live-topology-element-map", "proxy-report", "script-isolation"] }, "viewport": { "level": "experimental", "provider": "Three.js WebGL2", "operations": ["mesh-rendering", "multi-object-mesh-registry", "view-orientation", "fit-and-zoom", "subshape-triangle-mapping", "subshape-click-selection", "subshape-hover-preselection", "subshape-hover-highlight", "occt-analytic-picking-topology", "brep-edge-polyline-picking", "edge-pixel-tolerance-picking", "vertex-pixel-tolerance-picking", "diagnostic-selection-flash", "multi-object-selection-set", "modifier-box-selection-gesture", "window-contained-box-selection", "crossing-intersection-box-selection", "multi-object-selection-highlight", "object-visibility-projection", "selection-resource-release"] }, diff --git a/config/freecad-web-exact-parity-plan.json b/config/freecad-web-exact-parity-plan.json index 744e2ae..c7c8479 100644 --- a/config/freecad-web-exact-parity-plan.json +++ b/config/freecad-web-exact-parity-plan.json @@ -32,7 +32,7 @@ { "id": "EX-KER-01", "title": "Match OCCT construction, validation, tolerances and failure diagnostics", "priority": "P0", "status": "in_progress", "dependencies": ["EX-ORA-02"], "deliverables": ["Per-operation native differential", "Tolerance and placement contract", "Failure-code map"], "acceptance": ["Shape validity, topology, mass properties and diagnostics match locked FreeCAD fixtures", "Worker ownership returns to zero after every case"], "evidence": ["check:browser-occt", "check:chrome-geometry-features"], "exactBlockedBy": ["Not all FreeCAD builder flags and failure branches are represented"] }, { "id": "EX-TSN-00", "title": "Fail closed when native naming or builder evidence is absent", "priority": "P0", "status": "completed", "dependencies": [], "deliverables": ["Runtime naming evidence contract", "final-shape-only and ambiguous persistence", "ElementMap2 writer evidence guard"], "acceptance": ["Final geometry cannot mint a private FreeCAD token", "Unproven stage relations and isomorphic sources cannot become stable"], "evidence": ["check:freecad-native-naming-evidence", "check:freecad-exact-history-elementmap-gate"], "exactBlockedBy": [] }, { "id": "EX-TSN-01", "title": "Capture native stage Shape and Generated/Modified/Deleted for every builder", "priority": "P0", "status": "completed", "dependencies": ["EX-KER-01", "EX-TSN-00"], "deliverables": ["All builder-stage captures", "Multi-input and one-to-many lineage", "Local feature recompute history"], "acceptance": ["Every feature result relation has a producing native stage", "No final-result index is applied to an intermediate Shape"], "evidence": ["check:freecad-composite-history-elementmap", "check:freecad-tsn-stage-evidence"], "exactBlockedBy": [] }, - { "id": "EX-TSN-02", "title": "Expose native MappedNameRef and StringHasher decisions for every feature", "priority": "P0", "status": "in_progress", "dependencies": ["EX-TSN-01"], "deliverables": ["Native naming callback ABI", "Per-stage StringHasher evidence", "Lossless ElementMap2 token generation"], "acceptance": ["All new tokens originate in native evidence", "FreeCAD round-trip names remain byte and semantic stable"], "evidence": ["check:freecad-native-naming-evidence", "check:freecad-sketcher-partdesign-abi"], "exactBlockedBy": ["The shipped Worker does not link the FreeCAD private naming implementation for every builder"] }, + { "id": "EX-TSN-02", "title": "Expose native MappedNameRef and StringHasher decisions for every feature", "priority": "P0", "status": "in_progress", "dependencies": ["EX-TSN-01"], "deliverables": ["Native naming callback ABI", "Per-stage StringHasher evidence", "Lossless ElementMap2 token generation"], "acceptance": ["All new tokens originate in native evidence", "FreeCAD round-trip names remain byte and semantic stable"], "evidence": ["check:freecad-native-naming-evidence", "check:freecad-sketcher-partdesign-abi", "check:freecad-naming-sdk", "check:occt-history-artifact"], "exactBlockedBy": ["The shipped Worker does not link the FreeCAD private naming implementation for every builder"] }, { "id": "EX-TSN-03", "title": "Resolve isomorphic topology only from unique native provenance", "priority": "P0", "status": "in_progress", "dependencies": ["EX-TSN-01", "EX-TSN-02"], "deliverables": ["Symmetric Boolean corpus", "Candidate provenance graph", "Ambiguity repair lifecycle"], "acceptance": ["Unique native sources resolve deterministically", "Non-unique sources remain ambiguous across save, undo and recompute"], "evidence": ["test:topology-replay", "check:freecad-exact-history-elementmap-gate"], "exactBlockedBy": ["Symmetric cases without unique native provenance remain intentionally ambiguous"] }, { "id": "EX-TSN-04", "title": "Close topology naming across all feature edits and FCStd round-trips", "priority": "P0", "status": "pending", "dependencies": ["EX-TSN-02", "EX-TSN-03"], "deliverables": ["Cross-feature mutation matrix", "ElementMap2 history migration", "Long-chain LinkSub stability report"], "acceptance": ["Wrong bindings, unexplained relations and name drift are zero for the exhaustive corpus"], "evidence": ["check:freecad-fcstd-roundtrip", "check:freecad-exact-history-elementmap-gate"], "exactBlockedBy": ["Exhaustive cross-feature naming corpus is incomplete"] } ] diff --git a/config/project-migration-verification.json b/config/project-migration-verification.json index d32f0db..8b04ead 100644 --- a/config/project-migration-verification.json +++ b/config/project-migration-verification.json @@ -1,82 +1,98 @@ { "schemaVersion": 1, "status": "pass", - "currentSchemaVersion": 6, + "currentSchemaVersion": 7, "migrationVersions": [ 1, 2, 3, 4, 5, - 6 + 6, + 7 ], - "migrationSha256": "0308954a28a2cd5d98888cd9c97c25170760e00f8cb16133a51414c5ebedb5a0", + "migrationSha256": "ab987867c4935f60198ec19fb69259f6fc95167005bbb4f40e11723a52392f1d", "forward": [ { "from": 0, - "to": 6, + "to": 7, "applied": [ 1, 2, 3, 4, 5, - 6 + 6, + 7 ], "committed": true, "rolledBack": false }, { "from": 1, - "to": 6, + "to": 7, "applied": [ 2, 3, 4, 5, - 6 + 6, + 7 ], "committed": true, "rolledBack": false }, { "from": 2, - "to": 6, + "to": 7, "applied": [ 3, 4, 5, - 6 + 6, + 7 ], "committed": true, "rolledBack": false }, { "from": 3, - "to": 6, + "to": 7, "applied": [ 4, 5, - 6 + 6, + 7 ], "committed": true, "rolledBack": false }, { "from": 4, - "to": 6, + "to": 7, "applied": [ 5, - 6 + 6, + 7 ], "committed": true, "rolledBack": false }, { "from": 5, - "to": 6, + "to": 7, "applied": [ - 6 + 6, + 7 + ], + "committed": true, + "rolledBack": false + }, + { + "from": 6, + "to": 7, + "applied": [ + 7 ], "committed": true, "rolledBack": false @@ -138,6 +154,19 @@ ], "committed": false, "rolledBack": true + }, + { + "failedAt": 7, + "restoredVersions": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "committed": false, + "rolledBack": true } ], "idempotentRerun": { diff --git a/config/release-artifacts.json b/config/release-artifacts.json index 101522f..30b674a 100644 --- a/config/release-artifacts.json +++ b/config/release-artifacts.json @@ -22,25 +22,25 @@ "bytes": 556597, "sha256": "1679307fb0f01e9d7b9f37a081458bf44583e4d1899edf9224b49a1435ce7105" }, + { + "path": "assets/index-B7ENiszF.js", + "bytes": 1134628, + "sha256": "53bd3b2876d56238158be315cfdf8d7143767c46c0a429271d9985c36f003ca4" + }, { "path": "assets/index-BnRqhr3m.css", "bytes": 65976, "sha256": "b8eacf86224d2705e3c27367b3a0b166f4d662a5b8142d34a8abcdeef58753ec" }, { - "path": "assets/index-ChdGmY7w.js", - "bytes": 1130165, - "sha256": "c508b701c47d2dea412c96f3a529f99b42bd7b137e647d958b993c3268dfc3e6" + "path": "assets/nativeHistoryWorkerEntry-C7hZKjun.js", + "bytes": 21764, + "sha256": "a4fbc5e765e1d919c8f066b6b07e2064f62e3c291496972a2b4006069a17902b" }, { - "path": "assets/nativeHistoryWorkerEntry-gNsJxM2G.js", - "bytes": 20437, - "sha256": "39c26691ba3275ce5fbb3a43239d5511430bc0222259f0c76e3aa9314f2e87cf" - }, - { - "path": "assets/persistenceWorker-CyD5EUVX.js", - "bytes": 241190, - "sha256": "65a24ca0125444e3047f409d3b1c3667321e392a0ca9ddf64df6b728a1544c66" + "path": "assets/persistenceWorker-DQjay47f.js", + "bytes": 242249, + "sha256": "fa86b0667f07d1986e2d6dd50ee08ac291f83de07ab132db2ec13f306d49402b" }, { "path": "assets/planegcsWorkerEntry-BeAnOOBp.js", @@ -219,8 +219,8 @@ }, { "path": "chrome-opfs-migration-harness.html", - "bytes": 7032, - "sha256": "53203065bbc2937f63b7e0a9d17dd9a4e27396bbcef0766a87d5b8be45c8fb49" + "bytes": 7518, + "sha256": "553387802e86572a416bdd78a34d5e54855f6390f531f728c4749b5901e6c945" }, { "path": "chrome-partdesign-lifecycle-harness.html", @@ -330,7 +330,7 @@ { "path": "index.html", "bytes": 678, - "sha256": "ce1c0c151dfb290cff01268efaec2efd0cdc354548ffccb5cb1030b02249870f" + "sha256": "019c7f7b5255a12f94a19ae72edc50a45343e81779bed1b68842a934b31da01a" }, { "path": "manifest.webmanifest", @@ -380,7 +380,7 @@ { "path": "sw.js", "bytes": 1390, - "sha256": "a8d35592358f8580b3be765a36897969c63ebfb7c777a71296481e0d8118fad7" + "sha256": "5389aba344f93a8948bfed30aa20c0a1c38cdba7d9ebf120964d0897bba7e8cc" }, { "path": "vendor/camotics/camotics-sweep.wasm", @@ -402,7 +402,7 @@ "status": "signed", "algorithm": "Ed25519", "keyId": "bitbybit-local-release-2026", - "payloadSha256": "be48e099205c1a86339eb4560e62ed88e12ffd0e2e45e036f3a723abd3bb078a", - "signature": "AAyAo8hzt1I48LaHUArN7lEg11zWrl3EkNKF6zr92x2SZHFhXq/5m8KZ0hqJxNU4INM6bC/laqanCJ9GhRbkCQ==" + "payloadSha256": "20fb0fbc84390befc6fa22358b25cc139fb9dde8829643b5fc4ab3e804c44181", + "signature": "QyhRB17NFPFu9Nda1rU2KW+Lv5yROFM9Lo0vY9hOexecfwzb3PWw+XWHymLc1/hYjBm3Hw7dPdQ8Ou2KapWmBg==" } } diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 8080bc5..3d445bd 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -333,7 +333,7 @@ Bitbybit 必须被充分用作几何、Worker、IO、三角化和缓存的首选 属性领域合同现覆盖标量、枚举、颜色、Vector、Placement、ObjectLink、ObjectLinkList/StringList 和版本化 SubshapeLink。Placement/Vector 使用结构化值并做有限数、角度范围及非零旋转轴校验,复合值深拷贝进入 Undo/Redo 与持久化。Facade 的对象级几何重算已在 Shape 生成后执行 Placement,并对恒等变换、畸形结构、变换失败释放和最近有效缓存保留给出测试证据;这不是 FreeCAD Body/Tip、Support、AttachmentOffset 或特征局部坐标链的等价实现,File/Shape 摘要和多选 mixed 编辑也仍未完成。 -TSN 已完成面/边/顶点量化签名、邻接指纹消歧和匹配 API,并挂接到实际重算 generation。成功 Shape 会产生对象拓扑快照、迁移 matches、`signature-fallback`/`occt-native` 历史和 schema v1 ElementMap;提交阶段自动写回 LinkSub 与草图外部几何,稳定引用更新 generation,歧义/删除引用保持显式状态、标脏所有者并产生修复诊断。候选替换 Facade 事务验证当前拓扑和 kind 后原子写入 stable TopoRef,重建 DAG、进入 Undo/Redo;属性面板和诊断页均通过 Facade 调用。schema v6 保存快照。Three.js 已支持多对象 Mesh 注册、OCCT analytic 面/边/顶点点击、预选、完整边折线高亮、window/crossing 框选和诊断聚焦闪烁;普通单向 Pad/Pocket/Revolution/Groove 已接入已有 OCCT `Generated/Modified/Deleted` provider,其余复合模式仍显式回退。 +TSN 已完成面/边/顶点量化签名、邻接指纹消歧和匹配 API,并挂接到实际重算 generation。成功 Shape 会产生对象拓扑快照、迁移 matches、`signature-fallback`/`occt-native` 历史和 schema v1 ElementMap;提交阶段自动写回 LinkSub 与草图外部几何,稳定引用更新 generation,歧义/删除引用保持显式状态、标脏所有者并产生修复诊断。候选替换 Facade 事务验证当前拓扑和 kind 后原子写入 stable TopoRef,重建 DAG、进入 Undo/Redo;属性面板和诊断页均通过 Facade 调用。schema v7 保存拓扑快照和稳定原生对象标签。Three.js 已支持多对象 Mesh 注册、OCCT analytic 面/边/顶点点击、预选、完整边折线高亮、window/crossing 框选和诊断聚焦闪烁;普通单向 Pad/Pocket/Revolution/Groove 已接入已有 OCCT `Generated/Modified/Deleted` provider,其余复合模式仍显式回退。 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 仍是兼容门禁。 @@ -359,7 +359,7 @@ PartDesign Mirrored 已形成 experimental whole-shape 垂直切片:Facade 验 | 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 v6 与统一事务 migration runner;v6 新增对象拓扑快照 JSON;失败 rollback;写队列严格串行并在失败后继续 | migration 排序/跳过/回滚测试、拓扑快照 fallback 往返及 1000 次队列压力通过 | SQLite v4→v5→v6 的真实 OPFS 浏览器升级、崩溃注入与发布回滚演练仍待完成 | +| P2-01/P2-02 Migration/write pressure | schema v7 与统一事务 migration runner;v6 新增对象拓扑快照 JSON,v7 新增稳定原生对象标签;失败 rollback;写队列严格串行并在失败后继续 | migration 排序/跳过/回滚测试、真实 Chrome OPFS v1→v7、标签重开/checkpoint 往返及 1000 次队列压力通过 | 跨发布降级、断电 journal 恢复和用户数据备份仍待完成 | | 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`,完整修复建议和自交诊断仍待实现 | @@ -368,7 +368,7 @@ PartDesign Mirrored 已形成 experimental whole-shape 垂直切片:Facade 验 | P3-07 MultiTransform 实验切片 | `multi-transform` 使用结构化有序 Linear/Polar/Mirrored 步骤,Task 与属性面板共用编辑器;重算逐步扩展 whole-shape 实例并最终 union,限制最多 6 步和 100 个组合实例 | 6 实例组合、方向/平面映射、故障注入、中间 Shape 完整回收、最近有效缓存、深拷贝、Undo/Redo、fallback 保存恢复与任务提交测试通过 | 不是 FreeCAD 局部 Transform feature history;未实现特征列表、Datum/TopoRef 轴面、单实体规则与原生拓扑历史,保持 `experimental` | | P3-05 Hole 基础切片 | `hole` 创建 `PartDesign::Hole`,用 32 边圆形 Profile 或圆锥刀具调用现有 OCCT Pocket/Cut;支持 Diameter、DepthType/Depth、Position/Direction、Reversed、Flat/Angled DrillPoint、DrillForDepth、Tapered/TaperedAngle 以及 Dimension/ThroughAll,校验正尺寸并纳入 Base 链接和 Body.Tip | 圆孔 Profile、有限深度调用、位置/方向、90 度基准锥度、包含钻尖总深度、非法直径/方向诊断、Task 属性提交和 Tip 更新测试通过;Counterbore/Countersink/Counterdrill、Angled drill point 和 Tapered 由 Bitbybit 圆柱/圆锥刀具加 Boolean Cut 组合执行,并有 Chrome 非空 mesh 与 0/0 release 证据 | 仍未实现 Threaded/ModelThread、标准尺寸、Support/Profile 面定位和完整局部特征历史;螺纹保持显式 `HOLE_THREADING_UNSUPPORTED`,能力保持 `experimental` | | TSN-02/05/06/08 TopoRef 基础迁移 | `topologyReferences` 提供 schema v1 创建、序列化、安全解析、解析和跨版本迁移;重复几何通过邻接指纹唯一消歧,一旧多新不会错误继承;匹配按 kind 隔离并用形状尺度归一化几何评分支持纯平移;对象快照附带 schema v1 ElementMap;新增原生 ElementMap2 v1 token AST/schema v2 writer,以及 StringHasher v1 AST/schema v2 codec | round-trip、瞬时索引拒绝、resolved/ambiguous/deleted、重复拆分、邻接唯一/对称回归、FaceN/EdgeN/VertexN 写出和持久化通过;FreeCAD 1.1.1 的 30 模型、91 个 ElementMap2、11 个 StringHasher 表完成解析、写回和引用闭合 | Web 拓扑仍不能凭最终几何生成 FreeCAD 私有算法未提供的历史 token/StringID;对称 Boolean 的同构子形需要原生候选证据才能消歧 | -| TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef;重算 generation 自动迁移 LinkSub/草图外部几何,歧义/删除不强绑;schema v6 保存对象拓扑快照与 ElementMap;Facade 候选替换事务验证当前同 kind persistentId,重建 DAG 并支持 Undo/Redo;属性/诊断 UI 可替换;Three.js 多 Mesh 场景已将对象/OCCT analytic 面边点拾取、预选、完整曲线折线高亮、诊断闪烁和 window/crossing 框选回调 Facade;Pocket 暴露 `UpToFace` 槽;Pocket/Groove/Hole/MultiTransform 及双侧线性/角度特征可携带内部 builder 的 STEP/topology/history stage;ElementMap2 writer 可从 MappedNameRef/StringID 证据生成 FreeCAD 1.1.1 token,FCStd reader/writer 会校验 StringHasher 引用闭包 | 非法瞬时索引/候选拒绝、DAG 边、稳定/歧义/删除迁移、邻接消歧、候选替换、Undo/Redo、保存加载、无三角对角线的 BRep 边拾取、多选集合、真实双对象穿越框、诊断聚焦事件、live ElementMap、Pocket 双侧、Revolution/Groove 双角 Chrome 回放及 30 模型 naming-evidence gate 通过 | 未提供逐阶段 Shape/history 或 MappedNameRef/StringHasher 证据的算法继续保守回退;同构子形无唯一原生证据时不宣称 exact | +| TSN-08/09 LinkSub 文档接线 | `PropertyValue` 支持 schema v1 TopoRef;重算 generation 自动迁移 LinkSub/草图外部几何,歧义/删除不强绑;schema v7 保存对象拓扑快照、ElementMap 与稳定原生对象标签;Facade 候选替换事务验证当前同 kind persistentId,重建 DAG 并支持 Undo/Redo;属性/诊断 UI 可替换;Three.js 多 Mesh 场景已将对象/OCCT analytic 面边点拾取、预选、完整曲线折线高亮、诊断闪烁和 window/crossing 框选回调 Facade;Pocket 暴露 `UpToFace` 槽;Pocket/Groove/Hole/MultiTransform 及双侧线性/角度特征可携带内部 builder 的 STEP/topology/history stage;ElementMap2 writer 可从 MappedNameRef/StringID 证据生成 FreeCAD 1.1.1 token,FCStd reader/writer 会校验 StringHasher 引用闭包 | 非法瞬时索引/候选拒绝、DAG 边、稳定/歧义/删除迁移、邻接消歧、候选替换、Undo/Redo、保存加载、无三角对角线的 BRep 边拾取、多选集合、真实双对象穿越框、诊断聚焦事件、live ElementMap、Pocket 双侧、Revolution/Groove 双角 Chrome 回放及 30 模型 naming-evidence gate 通过 | 未提供逐阶段 Shape/history 或 MappedNameRef/StringHasher 证据的算法继续保守回退;同构子形无唯一原生证据时不宣称 exact | | TSN-03/07 历史适配器回退 | Bitbybit 1.1.1 绑定仅有 `HasGenerated()`,没有逐 Shape 的 Generated/Modified/IsDeleted;独立 native provider 已接入普通及双侧 Pad/Pocket/Revolution/Groove 参数路由与既有 Boolean/Pattern/Dress-up 子集;协议新增显式 input/stage、sourceStage/resultStage、一对多 resultIndexes、有序 feature sides 和 rotate stage,可向后兼容旧 object/tool 载荷;stage capture 按各自产生结果解释 index 并以邻接唯一性约束同构候选 | 平移修改、新增/删除/重复面、邻接唯一消歧、跨对象竞争、generation 写回、采集失败隔离、Up-to-face 有效距离、Two sides/Midplane/Two angles 路由通过;Groove 双角按 FreeCAD profile offset + 单 sweep + Cut 得到 native valid 单 solid;30 模型 history/ElementMap2/StringHasher exact gate 与 Facade `182/182` 通过 | provider 仍通过 STEP/BREP 跨 WASM;同构候选和未提供私有 builder/命名证据的算法仍需逐项黄金模型后才能声明 exact | 原生历史 provider 适配器现已进入重算执行器:当 Geometry Worker 暴露 `topologyHistory()` 且 Base/Tool/Profile 有拓扑快照时,Facade 将真实输入/输出 Shape 和记录交给 `captureNativeTopologyHistoryStages()`,再由 `composeNativeTopologyHistoryLineage()` 把上游对象 stage 接入当前关系;普通 Pad/Pocket/Revolution/Groove、Up-to-face 有效长度以及双侧/Midplane/Two angles 会进入同一参数协议。Pocket、Hole、MultiTransform 和双侧 builder 保存中间 STEP/拓扑/记录;Groove TwoAngles 按 FreeCAD 语义保存 `rotate -> revolution -> cut` 的 BREP/history,并已通过 native BRepCheck Chrome 黄金门。provider 不声明组成 operation 时保留单响应兼容路径。exact 判定仍必须同时拥有逐阶段 Shape/history 与 MappedNameRef/StringHasher 命名证据,任一缺失都不能由最终几何补推。 @@ -391,6 +391,8 @@ PartDesign Mirrored 已形成 experimental whole-shape 垂直切片:Facade 验 `FreeCADPrivateNamingABI v1` 已定义为版本化 Embind/JSON 合同,并接入 direct provider 与真实 Worker:请求携带 operation、输入 STEP、最终 STEP/BRep、阶段 DAG 和原生 history,响应必须同时提供 `MappedNameRef`、StringHasher 与 ElementMap2,且逐项通过 stage/result 绑定、大小上限、schema、StringID 和引用闭包校验。生产运行时声明 `freecad-private-v1-optional-worker`,mock 明确为 `not-exposed`;未导出三项回调、版本/锁定提交不符、证据不闭合或 operation 未声明时均 fail-closed 到 `final-shape-only`,禁止合成 token。当前仓库随附的 `native/occt-history` 仍是 OCCT-only artifact,没有链接 FreeCAD 私有实现,因此这里只完成 ABI 与传输边界,不把浏览器实时私有命名提升为 exact。 +ABI 输入现同时携带文档 `objectId`、FCStd 原生正整数 object tag、上一阶段命名证据、明确的 result object/tag 和阶段 DAG;object tag 会从 `Document.xml` 的 `Object.id` 导入,按稳定值写回 FCStd,并通过 SQLite schema v7、OPFS 重开和 checkpoint 往返。`build:freecad-naming-worker` 是独立的 fail-closed 构建入口,只接受锁定提交、Emscripten 3.1.69、wasm Qt/Python、FreeCADBase/FreeCADApp/Part 静态库及三回调 bridge 的哈希清单;普通 `build:occt-history` 继续是 OCCT-only。`check:occt-history-artifact` 会实际实例化 WASM 并探测三回调,当前结果为 unavailable,因此 `EX-TSN-02=in_progress` 与 `systemExact=false` 保持不变。 + Sketcher/PartDesign 的 Web 支持范围新增可执行参数合同:Sketcher 覆盖 FreeCAD 1.1.1 FCStd 表面的 9 类 geometry、19 类 constraint 和 10 类 InternalAlignment 语义;PartDesign 覆盖 Facade 已支持的 21 个 family、202 个属性槽和 72 个语义分区,并在对象创建及属性编辑时执行跨字段校验。`check:freecad-sketcher-partdesign-abi` 会从实现重算这些计数并拒绝陈旧报告;该“完整”限定于 supported Web Facade,不包含尚未实现的 FreeCAD PartDesign TypeId,也不表示 basic TypeScript solver 已求解全部圆锥曲线约束。 逐 feature 等级目前由 `config/compatibility-matrix.json` 的 `facadeCapabilities.geometry.featureLevels` 管理:Pad/Pocket/Revolution/Groove/Boolean 为有 native history 子集的 `compatible`,Fillet/Chamfer/MultiTransform 保持 `experimental`。`systemExactEvaluation.exact` 必须在所有 feature 的阶段 Shape/history、私有 token 证据和同构来源都闭合后才允许改为 true;当前值固定为 false,阻断项为私有 FreeCAD token 算法、没有原生 builder stage、以及无唯一同构来源。 diff --git a/native/occt-history/CMakeLists.txt b/native/occt-history/CMakeLists.txt index 204445e..a576cb6 100644 --- a/native/occt-history/CMakeLists.txt +++ b/native/occt-history/CMakeLists.txt @@ -13,6 +13,18 @@ find_package(OpenCASCADE REQUIRED CONFIG PATHS "${OCCT_DIR}" NO_DEFAULT_PATH) add_executable(bitbybit_occt_history occt_history.cpp) target_include_directories(bitbybit_occt_history PRIVATE "${OCCT_DIR}/include/opencascade") target_link_libraries(bitbybit_occt_history PRIVATE TKDESTEP TKXSBase TKDE TKBO TKBRep TKTopAlgo TKPrim TKShHealing TKGeomAlgo TKGeomBase TKG3d TKG2d TKMath TKernel) +if(FREECAD_WASM_SDK_DIR) + if(NOT FREECAD_NAMING_BRIDGE_SOURCE OR NOT EXISTS "${FREECAD_NAMING_BRIDGE_SOURCE}") + message(FATAL_ERROR "FREECAD_NAMING_BRIDGE_SOURCE must point to the verified FreeCAD private naming bridge source.") + endif() + if(NOT FREECAD_NAMING_INCLUDE_DIRS OR NOT FREECAD_NAMING_LIBRARIES) + message(FATAL_ERROR "FREECAD_NAMING_INCLUDE_DIRS and FREECAD_NAMING_LIBRARIES are required for a FreeCAD-linked Worker.") + endif() + target_sources(bitbybit_occt_history PRIVATE "${FREECAD_NAMING_BRIDGE_SOURCE}") + target_include_directories(bitbybit_occt_history PRIVATE ${FREECAD_NAMING_INCLUDE_DIRS}) + target_link_libraries(bitbybit_occt_history PRIVATE ${FREECAD_NAMING_LIBRARIES}) + target_compile_definitions(bitbybit_occt_history PRIVATE BITBYBIT_FREECAD_NAMING_LINKED=1) +endif() target_compile_options(bitbybit_occt_history PRIVATE -fexceptions -O2) target_link_options(bitbybit_occt_history PRIVATE --bind diff --git a/native/occt-history/README.md b/native/occt-history/README.md index 50aec2f..850d448 100644 --- a/native/occt-history/README.md +++ b/native/occt-history/README.md @@ -19,6 +19,35 @@ The build also publishes the JS/WASM pair to clean checkout must run the build after restoring the pinned OCCT source and Emscripten toolchain. +## FreeCAD private naming build boundary + +The default build is intentionally OCCT-only. It does not export +`freecadNamingAbiVersion`, `freecadNamingCapabilitiesJson`, or +`freecadNamingEvidenceJson`, and it must not be described as exact FreeCAD +naming support. + +A separate fail-closed build lane is available for an actual FreeCAD WASM SDK: + +```bash +FREECAD_WASM_SDK_DIR=/absolute/path/to/sdk ./npmw run check:freecad-naming-sdk +FREECAD_WASM_SDK_DIR=/absolute/path/to/sdk ./npmw run build:freecad-naming-worker +./npmw run check:occt-history-artifact +``` + +The SDK layout is defined by `freecad-wasm-sdk-manifest.example.json`. The +checker requires the locked FreeCAD 1.1.1 commit, Emscripten 3.1.69, wasm Qt +and Python targets, private App headers, hashed static FreeCAD/Part/Qt/Python +libraries, and a hashed bridge source exporting all three callbacks. If any +input is absent, `build:freecad-naming-worker` exits before rebuilding or +publishing the OCCT-only artifact. A successful artifact probe still only +establishes ABI linkage; exact promotion additionally requires valid +MappedNameRef, StringHasher, ElementMap2, stage history, and round-trip evidence. + +The versioned JSON request carries document `objectId`, stable positive +`objectTag`, prior naming evidence, result object identity, result tag, stage +DAG, STEP/BRep, and OCCT Generated/Modified/Deleted history. No Web fallback is +allowed to synthesize a FreeCAD private token. + The exported `booleanHistory(object, tool, operation)` function supports `fuse`, `cut`, and `common`. It returns the native result shape, per-input `modified`/`generated`/`deleted` records, a validity/topology/quality summary, diff --git a/native/occt-history/build.sh b/native/occt-history/build.sh index 927eff6..1b8058f 100755 --- a/native/occt-history/build.sh +++ b/native/occt-history/build.sh @@ -7,6 +7,15 @@ OCCT_BUILD_DIR="${OCCT_BUILD_DIR:-${ROOT_DIR}/.cache/bitbybit/occt-history-build DIST_DIR="${OCCT_HISTORY_DIST_DIR:-${ROOT_DIR}/native/occt-history/dist}" PUBLIC_DIR="${OCCT_HISTORY_PUBLIC_DIR:-${ROOT_DIR}/public/native/occt-history}" JOBS="${JOBS:-$(nproc)}" +FREECAD_WASM_SDK_DIR="${FREECAD_WASM_SDK_DIR:-}" + +if [[ "${FREECAD_WASM_NAMING_REQUIRED:-0}" == "1" && -z "${FREECAD_WASM_SDK_DIR}" ]]; then + echo "FREECAD_WASM_SDK_DIR is required for build:freecad-naming-worker; refusing to produce an OCCT-only artifact." >&2 + exit 1 +fi +if [[ -n "${FREECAD_WASM_SDK_DIR}" ]]; then + FREECAD_WASM_SDK_DIR="${FREECAD_WASM_SDK_DIR}" node "${ROOT_DIR}/scripts/check-freecad-naming-sdk.mjs" >/dev/null +fi if [[ ! -f "${OCCT_SOURCE_DIR}/CMakeLists.txt" ]]; then echo "OCCT source checkout not found: ${OCCT_SOURCE_DIR}" >&2 @@ -47,7 +56,19 @@ cmake --build "${OCCT_BUILD_DIR}" --target \ --parallel "${JOBS}" mkdir -p "${DIST_DIR}" -em++ "${ROOT_DIR}/native/occt-history/occt_history.cpp" \ +SOURCES=("${ROOT_DIR}/native/occt-history/occt_history.cpp") +INCLUDES=("-I${OCCT_BUILD_DIR}/include/opencascade" "-I${OCCT_SOURCE_DIR}/src") +DEFINES=() +FREECAD_LIBRARIES=() +if [[ -n "${FREECAD_WASM_SDK_DIR}" ]]; then + BRIDGE_SOURCE="$(node -e 'const fs=require("node:fs"); const p=require("node:path"); const m=JSON.parse(fs.readFileSync(p.resolve(process.argv[1],"manifest.json"),"utf8")); console.log(p.isAbsolute(m.namingBridge.source) ? m.namingBridge.source : p.resolve(process.argv[1],m.namingBridge.source))' "${FREECAD_WASM_SDK_DIR}")" + SOURCES+=("${BRIDGE_SOURCE}") + while IFS= read -r include_dir; do INCLUDES+=("-I${include_dir}"); done < <(node -e 'const fs=require("node:fs"); const p=require("node:path"); const root=p.resolve(process.argv[1]); const m=JSON.parse(fs.readFileSync(p.resolve(root,"manifest.json"),"utf8")); for (const d of m.includeDirs) console.log(p.isAbsolute(d) ? d : p.resolve(root,d))' "${FREECAD_WASM_SDK_DIR}") + while IFS= read -r library; do FREECAD_LIBRARIES+=("${library}"); done < <(node -e 'const fs=require("node:fs"); const p=require("node:path"); const root=p.resolve(process.argv[1]); const m=JSON.parse(fs.readFileSync(p.resolve(root,"manifest.json"),"utf8")); for (const l of m.libraries) console.log(p.isAbsolute(l.path) ? l.path : p.resolve(root,l.path))' "${FREECAD_WASM_SDK_DIR}") + DEFINES+=("-DBITBYBIT_FREECAD_NAMING_LINKED=1") +fi + +em++ "${SOURCES[@]}" "${INCLUDES[@]}" "${DEFINES[@]}" \ -I"${OCCT_BUILD_DIR}/include/opencascade" \ -I"${OCCT_SOURCE_DIR}/src" \ -std=c++17 -O2 -fexceptions --bind \ @@ -69,6 +90,7 @@ em++ "${ROOT_DIR}/native/occt-history/occt_history.cpp" \ "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKG2d.a" \ "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKMath.a" \ "${OCCT_BUILD_DIR}/lin32/clang/lib/libTKernel.a" \ + "${FREECAD_LIBRARIES[@]}" \ -Wl,--end-group \ -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker,node \ -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=134217728 -sMAXIMUM_MEMORY=2147483648 \ diff --git a/native/occt-history/freecad-wasm-sdk-manifest.example.json b/native/occt-history/freecad-wasm-sdk-manifest.example.json new file mode 100644 index 0000000..3ce1755 --- /dev/null +++ b/native/occt-history/freecad-wasm-sdk-manifest.example.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "freecadVersion": "1.1.1", + "sourceCommit": "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d", + "emscriptenVersion": "3.1.69", + "qtTarget": "wasm32-emscripten", + "pythonTarget": "wasm32-emscripten", + "includeDirs": [ + "include", + "include/FreeCAD" + ], + "libraries": [ + { "name": "FreeCADBase", "path": "lib/libFreeCADBase.a", "sha256": "replace-with-64-lowercase-hex-digits" }, + { "name": "FreeCADApp", "path": "lib/libFreeCADApp.a", "sha256": "replace-with-64-lowercase-hex-digits" }, + { "name": "Part", "path": "lib/libPart.a", "sha256": "replace-with-64-lowercase-hex-digits" }, + { "name": "QtCore", "path": "lib/libQt6Core.a", "sha256": "replace-with-64-lowercase-hex-digits" }, + { "name": "Python", "path": "lib/libpython3.a", "sha256": "replace-with-64-lowercase-hex-digits" } + ], + "namingBridge": { + "source": "src/freecad_naming_bridge.cpp", + "sha256": "replace-with-64-lowercase-hex-digits", + "exports": [ + "freecadNamingAbiVersion", + "freecadNamingCapabilitiesJson", + "freecadNamingEvidenceJson" + ] + } +} diff --git a/package.json b/package.json index 7d5e64c..21bd069 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "test:golden:freecad:failures": "node scripts/run-freecad-golden-failures.mjs", "test:golden:freecad:family-failures": "node scripts/run-freecad-golden-failures.mjs --manifest=fixtures/freecad-golden/feature-families/failures/manifest.json", "check:occt-history-artifact": "node scripts/check-occt-history-browser-artifact.mjs", + "check:freecad-naming-sdk": "node scripts/check-freecad-naming-sdk.mjs", "test:browser-occt": "node scripts/run-browser-occt-harness.mjs", "test:freecad:threeway": "node scripts/run-freecad-threeway-boolean.mjs", "check:browser-occt": "node scripts/check-occt-browser-verification.mjs", @@ -80,6 +81,7 @@ "check:freecad-cam-path": "node scripts/check-freecad-cam-path-oracle.mjs", "test:freecad-cam-path-report": "node --test tests/freecadCamPathOracle.test.mjs", "build:occt-history": "bash native/occt-history/build.sh", + "build:freecad-naming-worker": "FREECAD_WASM_NAMING_REQUIRED=1 bash native/occt-history/build.sh", "test:occt-history": "node native/occt-history/smoke-test.mjs", "build:planegcs": "bash native/planegcs/build.sh", "test:planegcs": "node native/planegcs/smoke-test.mjs", @@ -261,7 +263,7 @@ "test:browser-matrix": "node scripts/run-browser-matrix.mjs", "check:browser-matrix": "node scripts/check-browser-matrix.mjs", "check:runtime": "node scripts/check-runtime.mjs", - "verify": "npm run check:runtime && npm run check:baseline && npm run check:bitbybit-history && npm run check:execution-plan && npm run check:freecad-sketcher-constraints && npm run check:freecad-inventory && npm run check:freecad-type-properties && npm run check:freecad-gui-commands && npm run check:freecad-entrypoints && npm run check:freecad-golden-fixtures && npm run check:freecad-semantic-comparator && npm run check:freecad-fcstd-roundtrip && npm run check:freecad-composite-history-elementmap && npm run check:freecad-exact-history-elementmap-gate && npm run check:freecad-native-naming-evidence && npm run check:fcstd-hardening && npm run check:occt-history-artifact && npm run check:planegcs-artifact && npm run check:browser-occt && npm run check:chrome-topology && npm run check:chrome-planegcs && npm run check:chrome-fault-injection && npm run check:chrome-opfs-migration && npm run check:chrome-persistence && npm run check:chrome-app-e2e && npm run check:chrome-partdesign-lifecycle && npm run check:pwa && npm run check:chrome-offline && npm run check:chrome-geometry-features && npm run check:chrome-native-history && npm run check:chrome-native-pad-history && npm run check:chrome-native-pocket-history && npm run check:chrome-native-loft-history && npm run check:chrome-native-pipe-history && npm run check:chrome-native-revolution-history && npm run check:chrome-native-groove-history && npm run check:chrome-native-fillet-history && npm run check:chrome-native-chamfer-history && npm run check:chrome-native-hole-history && npm run check:chrome-native-draft-history && npm run check:chrome-native-thickness-history && npm run check:chrome-native-linear-pattern-history && npm run check:chrome-native-polar-pattern-history && npm run check:chrome-native-mirrored-history && npm run check:chrome-native-multi-transform-history && npm run check:chrome-sketcher-diagnostics && npm run check:chrome-profile-validation && npm run check:chrome-sketcher-editor && npm run check:chrome-sketcher-bspline && npm run check:chrome-sketcher-stress && npm run check:chrome-part-primitives && npm run check:chrome-partdesign-transform && npm run check:chrome-partdesign-loft && npm run check:partdesign-closure && npm run check:chrome-fcstd-semantic && npm run check:chrome-fcstd-roundtrip && npm run check:fcstd-closure && npm run check:chrome-spreadsheet && npm run check:chrome-plot && npm run check:chrome-draft && npm run check:chrome-techdraw && npm run check:chrome-assembly && npm run check:chrome-mesh && npm run check:chrome-inspection && npm run check:chrome-bim && npm run check:chrome-cam && npm run check:cam-parity && npm run check:cam-pipeline && npm run check:opencamlib-wasm && npm run check:camotics-native && npm run check:camotics-gui-tpl && npm run check:camotics-wasm && npm run check:chrome-camotics-wasm && npm run check:chrome-cam-native-simulation && npm run check:chrome-cam-linuxcnc-machine && npm run check:chrome-fem && npm run check:chrome-robot && npm run check:chrome-data && npm run check:chrome-addon && npm run check:chrome-script && npm run check:chrome-surface && npm run check:chrome-qa08 && npm run check:chrome-secondary-formats && npm run check:chrome-performance && npm run check:chrome-production-document && npm run check:document-closure && npm run check:chrome-engineering && npm run check:chrome-security && npm run check:chrome-fcstd-golden && npm run check:sbom && npm run check:project-migration && npm run check:security-baseline && npm run check:dependency-audit && npm run check:freecad:threeway && npm run check:occt-upstream-drift && npm run check:facade-boundary && npm run test:golden && npm run test:spreadsheet && npm run test:plot && npm run test:draft && npm run test:techdraw && npm run test:assembly && npm run test:mesh && npm run test:inspection && npm run test:bim && npm run test:cam && npm run test:camotics-wasm && npm run test:fem && npm run test:robot && npm run test:data && npm run test:addon && npm run test:script && npm run test:surface && npm run test:locale && npm run test:secondary-formats && npm run test:performance && npm run test:production-document && npm run test:engineering && npm run test:security-preflight && npm run test:fcstd-roundtrip && npm run test:facade && npm run test:fcstd-fuzz && npm run test:geometry-input-fuzz && npm run test:sketch-solver-fuzz && npm run check:quality-closure && npm run test:planegcs && npm run test:project-migration && npm run test:sketcher-stress && npm run test:topology-replay && npm run build && npm run check:built-pwa && npm run generate:release-artifacts && npm run check:release-artifacts && npm run check:release-closure && npm run test:release-signature", + "verify": "npm run check:runtime && npm run check:baseline && npm run check:bitbybit-history && npm run check:execution-plan && npm run check:freecad-sketcher-constraints && npm run check:freecad-inventory && npm run check:freecad-type-properties && npm run check:freecad-gui-commands && npm run check:freecad-entrypoints && npm run check:freecad-golden-fixtures && npm run check:freecad-semantic-comparator && npm run check:freecad-fcstd-roundtrip && npm run check:freecad-composite-history-elementmap && npm run check:freecad-exact-history-elementmap-gate && npm run check:freecad-native-naming-evidence && npm run check:fcstd-hardening && npm run check:freecad-naming-sdk && npm run check:occt-history-artifact && npm run check:planegcs-artifact && npm run check:browser-occt && npm run check:chrome-topology && npm run check:chrome-planegcs && npm run check:chrome-fault-injection && npm run check:chrome-opfs-migration && npm run check:chrome-persistence && npm run check:chrome-app-e2e && npm run check:chrome-partdesign-lifecycle && npm run check:pwa && npm run check:chrome-offline && npm run check:chrome-geometry-features && npm run check:chrome-native-history && npm run check:chrome-native-pad-history && npm run check:chrome-native-pocket-history && npm run check:chrome-native-loft-history && npm run check:chrome-native-pipe-history && npm run check:chrome-native-revolution-history && npm run check:chrome-native-groove-history && npm run check:chrome-native-fillet-history && npm run check:chrome-native-chamfer-history && npm run check:chrome-native-hole-history && npm run check:chrome-native-draft-history && npm run check:chrome-native-thickness-history && npm run check:chrome-native-linear-pattern-history && npm run check:chrome-native-polar-pattern-history && npm run check:chrome-native-mirrored-history && npm run check:chrome-native-multi-transform-history && npm run check:chrome-sketcher-diagnostics && npm run check:chrome-profile-validation && npm run check:chrome-sketcher-editor && npm run check:chrome-sketcher-bspline && npm run check:chrome-sketcher-stress && npm run check:chrome-part-primitives && npm run check:chrome-partdesign-transform && npm run check:chrome-partdesign-loft && npm run check:partdesign-closure && npm run check:chrome-fcstd-semantic && npm run check:chrome-fcstd-roundtrip && npm run check:fcstd-closure && npm run check:chrome-spreadsheet && npm run check:chrome-plot && npm run check:chrome-draft && npm run check:chrome-techdraw && npm run check:chrome-assembly && npm run check:chrome-mesh && npm run check:chrome-inspection && npm run check:chrome-bim && npm run check:chrome-cam && npm run check:cam-parity && npm run check:cam-pipeline && npm run check:opencamlib-wasm && npm run check:camotics-native && npm run check:camotics-gui-tpl && npm run check:camotics-wasm && npm run check:chrome-camotics-wasm && npm run check:chrome-cam-native-simulation && npm run check:chrome-cam-linuxcnc-machine && npm run check:chrome-fem && npm run check:chrome-robot && npm run check:chrome-data && npm run check:chrome-addon && npm run check:chrome-script && npm run check:chrome-surface && npm run check:chrome-qa08 && npm run check:chrome-secondary-formats && npm run check:chrome-performance && npm run check:chrome-production-document && npm run check:document-closure && npm run check:chrome-engineering && npm run check:chrome-security && npm run check:chrome-fcstd-golden && npm run check:sbom && npm run check:project-migration && npm run check:security-baseline && npm run check:dependency-audit && npm run check:freecad:threeway && npm run check:occt-upstream-drift && npm run check:facade-boundary && npm run test:golden && npm run test:spreadsheet && npm run test:plot && npm run test:draft && npm run test:techdraw && npm run test:assembly && npm run test:mesh && npm run test:inspection && npm run test:bim && npm run test:cam && npm run test:camotics-wasm && npm run test:fem && npm run test:robot && npm run test:data && npm run test:addon && npm run test:script && npm run test:surface && npm run test:locale && npm run test:secondary-formats && npm run test:performance && npm run test:production-document && npm run test:engineering && npm run test:security-preflight && npm run test:fcstd-roundtrip && npm run test:facade && npm run test:fcstd-fuzz && npm run test:geometry-input-fuzz && npm run test:sketch-solver-fuzz && npm run check:quality-closure && npm run test:planegcs && npm run test:project-migration && npm run test:sketcher-stress && npm run test:topology-replay && npm run build && npm run check:built-pwa && npm run generate:release-artifacts && npm run check:release-artifacts && npm run check:release-closure && npm run test:release-signature", "test:chrome-native-thickness-history": "node scripts/run-chrome-native-thickness-history.mjs", "check:chrome-native-thickness-history": "node scripts/check-chrome-native-thickness-history.mjs", "test:chrome-native-linear-pattern-history": "node scripts/run-chrome-native-linear-pattern-history.mjs", diff --git a/public/chrome-opfs-migration-harness.html b/public/chrome-opfs-migration-harness.html index 20d2964..09431e7 100644 --- a/public/chrome-opfs-migration-harness.html +++ b/public/chrome-opfs-migration-harness.html @@ -54,7 +54,7 @@ try { const loaded = await persistence.load(documentId) const restoredResource = await persistence.resource.get(resourceHash) const recovery = await persistence.recovery(documentId) - const migratedDocument = loaded ? { ...loaded, version: loaded.version + 1, dirty: false, label: 'Migrated OPFS project' } : null + const migratedDocument = loaded ? { ...loaded, version: loaded.version + 1, dirty: false, label: 'Migrated OPFS project', objects: loaded.objects.map((object, index) => ({ ...object, nativeObjectTag: 17 + index })) } : null if (!migratedDocument) throw new Error('Legacy document did not load after migration.') await persistence.save(migratedDocument) await persistence.dispose() @@ -73,9 +73,12 @@ try { migratedDocumentVersion: loaded?.version ?? null, migratedLabel: loaded?.label ?? null, migratedObjectCount: loaded?.objects.length ?? 0, + migratedNativeObjectTag: loaded?.objects[0]?.nativeObjectTag ?? null, recomputeDefaulted: loaded?.recompute?.status === 'idle', reopenedSchemaVersion: reopenedCapabilities.schemaVersion, reopenedVersion: reopenedDocument?.version ?? null, + reopenedNativeObjectTag: reopenedDocument?.objects[0]?.nativeObjectTag ?? null, + checkpointNativeObjectTag: checkpoint?.objects[0]?.nativeObjectTag ?? null, reopenedLabel: reopenedDocument?.label ?? null, checkpointVersion: checkpoint?.version ?? null, recoveryIntegrity: recovery.integrity, @@ -85,7 +88,7 @@ try { } report.rollback = { rejected: rollbackError.includes('interruption after schema version 3'), interruptedAfterMarker: rollbackError.includes('interruption after schema version 3'), error: rollbackError, appliedVersionsAfterFailure: afterRollback.appliedVersions || [] } report.cleanup = { databasePath, removedFiles: [] } - report.status = report.crossOriginIsolated && migrated.mode === 'sqlite-opfs' && migrated.schemaVersion === 6 && report.rollback.rejected && report.rollback.interruptedAfterMarker && report.rollback.appliedVersionsAfterFailure.length === 1 && report.rollback.appliedVersionsAfterFailure[0] === 1 && report.migration.migratedDocumentVersion === 3 && report.migration.migratedObjectCount === 1 && report.migration.recomputeDefaulted && report.migration.reopenedSchemaVersion === 6 && report.migration.reopenedVersion === 4 && report.migration.checkpointVersion === 4 && report.migration.recoveryIntegrity === 'ok' && report.migration.reopenedRecoveryIntegrity === 'ok' && report.migration.resourceRoundTrip && report.migration.resourceReleased ? 'pass' : 'failed' + report.status = report.crossOriginIsolated && migrated.mode === 'sqlite-opfs' && migrated.schemaVersion === 7 && report.rollback.rejected && report.rollback.interruptedAfterMarker && report.rollback.appliedVersionsAfterFailure.length === 1 && report.rollback.appliedVersionsAfterFailure[0] === 1 && report.migration.migratedDocumentVersion === 3 && report.migration.migratedObjectCount === 1 && report.migration.migratedNativeObjectTag === 1 && report.migration.recomputeDefaulted && report.migration.reopenedSchemaVersion === 7 && report.migration.reopenedVersion === 4 && report.migration.reopenedNativeObjectTag === 17 && report.migration.checkpointNativeObjectTag === 17 && report.migration.checkpointVersion === 4 && report.migration.recoveryIntegrity === 'ok' && report.migration.reopenedRecoveryIntegrity === 'ok' && report.migration.resourceRoundTrip && report.migration.resourceReleased ? 'pass' : 'failed' } catch (error) { report.status = 'failed' report.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error) diff --git a/scripts/check-chrome-opfs-migration.mjs b/scripts/check-chrome-opfs-migration.mjs index f4a0af2..f011714 100644 --- a/scripts/check-chrome-opfs-migration.mjs +++ b/scripts/check-chrome-opfs-migration.mjs @@ -5,7 +5,7 @@ const root = resolve(new URL('..', import.meta.url).pathname) const report = JSON.parse(await readFile(resolve(root, 'config/chrome-opfs-migration-verification.json'), 'utf8')) if (report.schemaVersion !== 1 || report.browserId !== 'chrome' || report.status !== 'pass' || report.crossOriginIsolated !== true) throw new Error('Chrome REL-02 OPFS migration evidence is not passing.') const migration = report.migration -if (!migration || migration.sourceSchemaVersion !== 1 || migration.migratedSchemaVersion !== 6 || migration.migratedDocumentVersion !== 3 || migration.migratedObjectCount !== 1 || migration.recomputeDefaulted !== true || migration.reopenedSchemaVersion !== 6 || migration.reopenedVersion !== 4 || migration.checkpointVersion !== 4 || migration.recoveryIntegrity !== 'ok' || migration.reopenedRecoveryIntegrity !== 'ok' || migration.resourceRoundTrip !== true || migration.resourceReleased !== true) throw new Error('Chrome REL-02 forward migration, reopen, checkpoint, or resource evidence is invalid.') +if (!migration || migration.sourceSchemaVersion !== 1 || migration.migratedSchemaVersion !== 7 || migration.migratedDocumentVersion !== 3 || migration.migratedObjectCount !== 1 || migration.migratedNativeObjectTag !== 1 || migration.recomputeDefaulted !== true || migration.reopenedSchemaVersion !== 7 || migration.reopenedVersion !== 4 || migration.reopenedNativeObjectTag !== 17 || migration.checkpointNativeObjectTag !== 17 || migration.checkpointVersion !== 4 || migration.recoveryIntegrity !== 'ok' || migration.reopenedRecoveryIntegrity !== 'ok' || migration.resourceRoundTrip !== true || migration.resourceReleased !== true) throw new Error('Chrome REL-02 forward migration, native object tag, reopen, checkpoint, or resource evidence is invalid.') if (!report.rollback || report.rollback.rejected !== true || report.rollback.interruptedAfterMarker !== true || !/interruption after schema version 3/.test(report.rollback.error || '') || JSON.stringify(report.rollback.appliedVersionsAfterFailure) !== '[1]') throw new Error('Chrome REL-02 SQLite interruption rollback evidence is invalid.') if (!report.cleanup || !Array.isArray(report.cleanup.removedFiles) || report.cleanup.removedFiles.length === 0 || report.cleanup.removedFiles.some((name) => typeof name !== 'string' || !name.startsWith(report.cleanup.databasePath.slice(1)))) throw new Error('Chrome REL-02 OPFS database cleanup evidence is invalid.') console.log(JSON.stringify({ status: 'chrome-opfs-migration-pass', browserId: report.browserId, migration, cleanup: report.cleanup }, null, 2)) diff --git a/scripts/check-freecad-naming-sdk.mjs b/scripts/check-freecad-naming-sdk.mjs new file mode 100644 index 0000000..ef7d232 --- /dev/null +++ b/scripts/check-freecad-naming-sdk.mjs @@ -0,0 +1,60 @@ +import { access, readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { resolve, isAbsolute } from 'node:path' + +const root = resolve(new URL('..', import.meta.url).pathname) +const sdkRoot = process.env.FREECAD_WASM_SDK_DIR ? resolve(root, process.env.FREECAD_WASM_SDK_DIR) : '' +const required = (value, name) => { + if (typeof value !== 'string' || !value.trim()) throw new Error(`FreeCAD WASM SDK manifest requires ${name}.`) + return value.trim() +} +const exists = async (path) => access(path).then(() => true).catch(() => false) +const sha256 = async (path) => createHash('sha256').update(await readFile(path)).digest('hex') +const fail = (message) => { throw new Error(`FreeCAD WASM naming SDK: ${message}`) } + +if (!sdkRoot) { + console.log(JSON.stringify({ status: 'sdk-not-configured', availability: 'unavailable', systemExact: false, reason: 'FREECAD_WASM_SDK_DIR is not configured; the shipped Worker remains OCCT-only.' }, null, 2)) + process.exit(0) +} + +const manifestPath = resolve(sdkRoot, 'manifest.json') +if (!await exists(manifestPath)) fail(`missing manifest: ${manifestPath}`) +let manifest +try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')) } catch (error) { fail(`manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}`) } +if (manifest.schemaVersion !== 1) fail(`unsupported manifest schema ${String(manifest.schemaVersion)}.`) +if (required(manifest.freecadVersion, 'freecadVersion') !== '1.1.1') fail('freecadVersion must be 1.1.1.') +if (required(manifest.sourceCommit, 'sourceCommit') !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d') fail('sourceCommit is not the locked FreeCAD commit.') +if (required(manifest.emscriptenVersion, 'emscriptenVersion') !== '3.1.69') fail('emscriptenVersion must be 3.1.69.') +if (required(manifest.qtTarget, 'qtTarget') !== 'wasm32-emscripten' || required(manifest.pythonTarget, 'pythonTarget') !== 'wasm32-emscripten') fail('Qt and Python must both be wasm32-emscripten targets.') +const includeDirs = Array.isArray(manifest.includeDirs) ? manifest.includeDirs : fail('includeDirs must be an array.') +const resolvedIncludeDirs = includeDirs.map((includeDir) => isAbsolute(includeDir) ? includeDir : resolve(sdkRoot, includeDir)) +const libraries = Array.isArray(manifest.libraries) ? manifest.libraries : fail('libraries must be an array.') +const libraryNames = new Set(libraries.map((library) => library?.name)) +for (const name of ['FreeCADBase', 'FreeCADApp', 'Part', 'QtCore', 'Python']) if (!libraryNames.has(name)) fail(`libraries must include ${name}.`) +const namingBridge = manifest.namingBridge && typeof manifest.namingBridge === 'object' ? manifest.namingBridge : fail('namingBridge is required.') +const bridgePath = required(namingBridge.source, 'namingBridge.source') +const bridge = isAbsolute(bridgePath) ? bridgePath : resolve(sdkRoot, bridgePath) +if (!await exists(bridge)) fail(`missing naming bridge source: ${bridge}`) +if (!Array.isArray(namingBridge.exports) || !['freecadNamingAbiVersion', 'freecadNamingCapabilitiesJson', 'freecadNamingEvidenceJson'].every((name) => namingBridge.exports.includes(name))) fail('namingBridge.exports must declare all three versioned ABI callbacks.') +for (const path of resolvedIncludeDirs) { + if (!await exists(path)) fail(`missing include directory: ${path}`) +} +for (const library of libraries) { + if (!library || typeof library !== 'object') fail('libraries entries must be objects.') + const name = required(library.name, 'libraries[].name') + const pathValue = required(library.path, `libraries[${name}].path`) + const path = isAbsolute(pathValue) ? pathValue : resolve(sdkRoot, pathValue) + if (!path.endsWith('.a')) fail(`library ${name} must be a static .a archive.`) + if (!await exists(path)) fail(`missing static library ${name}: ${path}`) + if ((await readFile(path)).byteLength === 0) fail(`static library ${name} is empty.`) + if (typeof library.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(library.sha256)) fail(`library ${name} requires a lowercase SHA-256.`) + const actualHash = await sha256(path) + if (actualHash !== library.sha256) fail(`library ${name} hash mismatch: expected ${library.sha256}, got ${actualHash}.`) +} +for (const header of ['App/StringHasher.h', 'App/MappedName.h', 'App/ElementMap.h']) { + if (!(await Promise.all(resolvedIncludeDirs.map((includeDir) => exists(resolve(includeDir, header))))).some(Boolean)) fail(`missing locked FreeCAD private header in includeDirs: ${header}`) +} +const bridgeSha = await sha256(bridge) +if (typeof namingBridge.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(namingBridge.sha256)) fail('namingBridge.sha256 requires a lowercase SHA-256.') +if (namingBridge.sha256 !== bridgeSha) fail(`naming bridge hash mismatch: expected ${namingBridge.sha256}, got ${bridgeSha}.`) +console.log(JSON.stringify({ status: 'sdk-ready', availability: 'available', systemExact: false, sdkRoot, freecadVersion: manifest.freecadVersion, sourceCommit: manifest.sourceCommit, emscriptenVersion: manifest.emscriptenVersion, includeDirs, libraries: libraries.map(({ name, path, sha256 }) => ({ name, path, sha256 })), namingBridge: { source: bridgePath, sha256: bridgeSha, exports: [...namingBridge.exports] } }, null, 2)) diff --git a/scripts/check-occt-history-browser-artifact.mjs b/scripts/check-occt-history-browser-artifact.mjs index b5e9034..7929d4f 100644 --- a/scripts/check-occt-history-browser-artifact.mjs +++ b/scripts/check-occt-history-browser-artifact.mjs @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { access, readFile } from 'node:fs/promises' import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' const root = resolve(new URL('..', import.meta.url).pathname) const artifactNames = ['bitbybit-occt-history.js', 'bitbybit-occt-history.wasm'] @@ -27,4 +28,56 @@ const workerClient = await readFile(resolve(root, 'src/facade/nativeHistoryWorke if (!workerClient.includes("'/native/occt-history/bitbybit-occt-history.js'")) throw new Error('Native history Worker client URL does not match the published artifact.') const files = [] for (const name of artifactNames) files.push({ name, sha256: hash(await readFile(resolve(publicRoot, name))) }) -console.log(JSON.stringify({ status: 'artifact-pass', files }, null, 2)) +let namingAbi = { availability: 'unavailable', callbacks: [] } +const factoryModule = await import(pathToFileURL(resolve(distRoot, 'bitbybit-occt-history.js')).href) +const factory = factoryModule.default +if (typeof factory !== 'function') throw new TypeError('OCCT history artifact default export is not a factory.') +const nativeModule = await factory({ locateFile: (fileName) => resolve(distRoot, fileName.split('/').at(-1)) }) +const callbacks = ['freecadNamingAbiVersion', 'freecadNamingCapabilitiesJson', 'freecadNamingEvidenceJson'].filter((name) => typeof nativeModule[name] === 'function') +if (callbacks.length !== 0 && callbacks.length !== 3) throw new Error(`FreeCAD naming ABI is partially exported: ${callbacks.join(', ')}.`) +if (callbacks.length === 3) { + const abiVersion = nativeModule.freecadNamingAbiVersion() + const descriptor = JSON.parse(nativeModule.freecadNamingCapabilitiesJson()) + if (abiVersion !== 1 || descriptor.schemaVersion !== 1 || descriptor.freecadVersion !== '1.1.1' || descriptor.sourceCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || descriptor.mappedNameRef !== true || descriptor.stringHasher !== true || descriptor.elementMap2 !== true || !Array.isArray(descriptor.operations) || !descriptor.operations.includes('cut')) throw new Error('FreeCAD naming ABI descriptor is not the locked v1 contract with Cut support.') + let object + let tool + let evidence + try { + object = nativeModule.makeBox(10, 10, 10) + tool = nativeModule.makeBox(5, 5, 5) + const objectStep = nativeModule.shapeToStep(object) + const toolStep = nativeModule.shapeToStep(tool) + const history = nativeModule.booleanHistoryFromStep(objectStep, toolStep, 'cut') + const request = { + schemaVersion: 1, + requestId: 'artifact-private-naming-probe', + documentId: 'artifact-probe-document', + documentVersion: 1, + operationId: 'artifact-probe-cut', + operation: 'cut', + stageId: 'artifact-probe:stage:0', + resultObjectId: 'artifact-probe:result', + resultObjectTag: 3, + inputs: [ + { inputId: 'object', objectId: 'artifact-probe:object', role: 'object', step: objectStep, objectTag: 1 }, + { inputId: 'tool', objectId: 'artifact-probe:tool', role: 'tool', step: toolStep, objectTag: 2 }, + ], + stages: [{ stageId: 'artifact-probe:stage:0', operation: 'cut', inputIds: ['object', 'tool'], ordinal: 0 }], + resultStep: history.resultStep, + resultBrep: history.resultBrep, + history, + } + evidence = JSON.parse(nativeModule.freecadNamingEvidenceJson(JSON.stringify(request))) + } finally { + object?.delete?.() + tool?.delete?.() + } + if (!evidence || evidence.schemaVersion !== 1 || evidence.stageId !== 'artifact-probe:stage:0' || evidence.resultObjectId !== 'artifact-probe:result' || !['native-evidence', 'ambiguous'].includes(evidence.status) || !Array.isArray(evidence.mappedNames) || evidence.mappedNames.length === 0 || !evidence.stringHasher || !evidence.elementMap2) throw new Error('FreeCAD naming ABI did not return complete stage-bound Cut evidence.') + namingAbi = { availability: 'available', callbacks, abiVersion, descriptor, evidenceProbe: { operation: 'cut', status: evidence.status, mappedNames: evidence.mappedNames.length, stringHasher: true, elementMap2: true } } +} +const abiContract = JSON.parse(await readFile(resolve(root, 'config/freecad-sketcher-partdesign-abi-contract.json'), 'utf8')) +const expectedImplementation = abiContract.privateNamingAbi?.shippedWorkerImplementation +if (expectedImplementation === 'not-linked' && namingAbi.availability !== 'unavailable') throw new Error('OCCT history artifact exports FreeCAD naming callbacks but the shipped-worker contract still says not-linked.') +if (expectedImplementation === 'freecad-linked' && namingAbi.availability !== 'available') throw new Error('Shipped-worker contract claims FreeCAD linkage but the artifact ABI probe is unavailable.') +if (!['not-linked', 'freecad-linked'].includes(expectedImplementation)) throw new Error('Shipped-worker FreeCAD naming implementation status is invalid.') +console.log(JSON.stringify({ status: 'artifact-pass', files, namingAbi, exactBoundary: namingAbi.availability === 'available' ? 'probe-only; FreeCAD evidence still requires locked descriptor and response validation' : 'occt-only; EX-TSN-02 remains in_progress and systemExact=false' }, null, 2)) diff --git a/scripts/check-quality-closure.mjs b/scripts/check-quality-closure.mjs index a32f00c..27bf9a4 100644 --- a/scripts/check-quality-closure.mjs +++ b/scripts/check-quality-closure.mjs @@ -59,7 +59,7 @@ if (sketchFuzz.status !== 'sketch-solver-fuzz-pass' || sketchFuzz.models !== 200 if (performance.status !== 'pass' || performance.benchmark?.objects !== 1000 || performance.benchmark.triangles !== 1_000_000 || performance.benchmark.tableCells !== 100_000 || performance.benchmark.pass !== true || performance.afterRelease?.shapeCount !== 0) throw new Error('QA-05 performance and ownership evidence is incomplete.') if (fault.status !== 'pass' || fault.lifecycle?.completed !== 'completed' || fault.lifecycle.cancelled !== 'cancelled' || fault.lifecycle.crashInjected !== true || fault.lifecycle.recovered !== 'completed' || fault.lifecycle.stale !== 'stale' || fault.opfs?.markerRemoved !== true) throw new Error('QA-06 Worker/WASM fault evidence is incomplete.') -if (opfsMigration.status !== 'pass' || opfsMigration.migration?.migratedSchemaVersion !== 6 || opfsMigration.migration.recoveryIntegrity !== 'ok' || opfsMigration.rollback?.rejected !== true) throw new Error('QA-06 OPFS recovery evidence is incomplete.') +if (opfsMigration.status !== 'pass' || opfsMigration.migration?.migratedSchemaVersion !== 7 || opfsMigration.migration.reopenedNativeObjectTag !== 17 || opfsMigration.migration.recoveryIntegrity !== 'ok' || opfsMigration.rollback?.rejected !== true) throw new Error('QA-06 OPFS recovery evidence is incomplete.') if (security.status !== 'pass' || security.checks?.rejectedCases !== 4 || security.checks.pass !== true || addon.security?.forgedRejected !== true || addon.security.permissionRejected !== true || script.security?.noDynamicExecution !== true || script.security.deniedCapabilities?.length !== 4) throw new Error('QA-07 security evidence is incomplete.') if (qa08.status !== 'pass' || qa08.locales?.supported !== 3 || qa08.accessibility?.pass !== true || app.keyboard?.named !== 8 || app.screenReader?.namedControls !== app.screenReader?.controls || app.screenReader?.unnamedControls !== 0 || app.mobile?.bodyHorizontalOverflow > 1) throw new Error('QA-08 accessibility/i18n evidence is incomplete.') diff --git a/scripts/check-release-closure.mjs b/scripts/check-release-closure.mjs index ae2f085..1a58c9b 100644 --- a/scripts/check-release-closure.mjs +++ b/scripts/check-release-closure.mjs @@ -30,6 +30,6 @@ await access(resolve(root, 'docs/release-runbook.zh-CN.md')).catch(() => fail('r if (release.schemaVersion !== 1 || release.signature?.status !== 'signed' || release.signature.algorithm !== 'Ed25519' || !release.signature.keyId || !/^[0-9a-f]{64}$/.test(release.signature.payloadSha256 || '') || release.build?.files?.length === 0) fail('release artifact manifest is not signed and complete.') if (sbom.bomFormat !== 'CycloneDX' || sbom.components?.length !== audit.metadata?.dependencies?.total) fail('SBOM is missing, stale, or inconsistent with the locked audit inventory.') if (offline.status !== 'pass' || offline.serviceWorker?.offlineFallback !== true || offline.serviceWorker?.staleCacheRemoved !== true) fail('offline deployment evidence is incomplete.') -if (migration.status !== 'pass' || migration.currentSchemaVersion !== 6 || migration.forward?.length !== 6 || migration.rollback?.length !== 6) fail('migration/rollback evidence is incomplete.') +if (migration.status !== 'pass' || migration.currentSchemaVersion !== 7 || migration.forward?.length !== 7 || migration.rollback?.length !== 7) fail('migration/rollback evidence is incomplete.') if (performance.status !== 'pass' || performance.benchmark?.pass !== true || security.status !== 'pass' || security.checks?.pass !== true || quality.status !== 'quality-closure-pass') fail('quality evidence is incomplete.') console.log(JSON.stringify({ status: 'release-closure-pass', gates: matrix.gates.length, modules: coverage.modules.length, moduleCounts: matrix.moduleCounts, signed: true, sbomComponents: sbom.components.length, migrationVersions: migration.currentSchemaVersion }, null, 2)) diff --git a/src/facade/fcstd.ts b/src/facade/fcstd.ts index cbdeda2..64b4532 100644 --- a/src/facade/fcstd.ts +++ b/src/facade/fcstd.ts @@ -52,6 +52,7 @@ export type FcstdObjectSummary = { name: string label: string typeId: string + nativeObjectTag?: number propertyCount: number properties: FcstdPropertySummary[] support: FcstdObjectSupport @@ -1103,16 +1104,32 @@ export const serializeFcstdMetadataArchive = (document: DocumentSnapshot, option const objectIds = document.objects.map((object) => object.id.trim()) if (objectIds.some((objectId) => !objectId)) throw new TypeError('FCStd metadata writer requires non-empty object ids.') if (new Set(objectIds).size !== objectIds.length) throw new TypeError('FCStd metadata writer requires unique object ids.') + const explicitObjectTags = new Set() + for (const object of document.objects) { + if (object.nativeObjectTag === undefined) continue + if (!Number.isSafeInteger(object.nativeObjectTag) || object.nativeObjectTag <= 0) throw new RangeError(`FCStd object ${object.id} nativeObjectTag must be a positive safe integer.`) + if (explicitObjectTags.has(object.nativeObjectTag)) throw new RangeError(`FCStd nativeObjectTag ${object.nativeObjectTag} is assigned to more than one object.`) + explicitObjectTags.add(object.nativeObjectTag) + } + let nextObjectTag = 1 + const objectTags = new Map(document.objects.map((object) => { + if (object.nativeObjectTag !== undefined) return [object.id, object.nativeObjectTag] as const + while (explicitObjectTags.has(nextObjectTag)) nextObjectTag += 1 + const tag = nextObjectTag + explicitObjectTags.add(tag) + nextObjectTag += 1 + return [object.id, tag] as const + })) const objectIdSet = new Set(objectIds) const dependencies = new Map(objectIds.map((objectId) => [objectId, [] as string[]])) for (const edge of document.dependencies ?? []) { if (!objectIdSet.has(edge.sourceId) || !objectIdSet.has(edge.targetId)) throw new Error(`FCStd dependency ${edge.sourceId} -> ${edge.targetId} references an undeclared object.`) if (edge.relation !== 'view') dependencies.get(edge.sourceId)?.push(edge.targetId) } - const objectDeclarations = document.objects.map((object, objectIndex) => { + const objectDeclarations = document.objects.map((object) => { const targets = dependencies.get(object.id) ?? [] const dependencyXml = targets.length === 0 ? `` : `${targets.map((target) => ``).join('')}` - return `${dependencyXml}` + return `${dependencyXml}` }).join('') const objectData = document.objects.map((object) => { const sketchGeneratedPropertyNames = object.sketch ? new Set(['Geometry', 'Constraints', 'ExternalGeo', 'ExternalGeometry', 'ExternalTypes', 'WebGeometryIds', 'WebSyntheticConstraintIds', 'WebExternalIds', 'WebExternalProjectionIds', 'WebExternalSources']) : new Set() @@ -2014,9 +2031,18 @@ const parseDocumentXml = (bytes: Uint8Array, limits: FcstdArchiveLimits) => { const dataByName = new Map(objectData.map((data) => [attribute(data, 'name'), data])) const documentProperties = asArray((((root.Properties as Record | undefined)?.Property) as Record | Record[] | undefined)) const labelProperty = documentProperties.find((property) => attribute(property, 'name') === 'Label') + const nativeObjectTags = new Set() const objects = objectDeclarations.map((declaration): FcstdObjectSummary => { const name = attribute(declaration, 'name') || '' const typeId = attribute(declaration, 'type') || 'App::DocumentObject' + const objectTagText = attribute(declaration, 'id') + let nativeObjectTag: number | undefined + if (objectTagText) { + if (!/^\d+$/.test(objectTagText) || !Number.isSafeInteger(Number(objectTagText)) || Number(objectTagText) <= 0) throw new Error(`FCStd object ${name} has an invalid native id.`) + nativeObjectTag = Number(objectTagText) + if (nativeObjectTags.has(nativeObjectTag)) throw new Error(`FCStd object native id ${nativeObjectTag} is duplicated.`) + nativeObjectTags.add(nativeObjectTag) + } const data = dataByName.get(name) const propertiesContainer = data?.Properties as Record | undefined const properties = asArray(((propertiesContainer?.Property) as Record | Record[] | undefined)) @@ -2029,7 +2055,7 @@ const parseDocumentXml = (bytes: Uint8Array, limits: FcstdArchiveLimits) => { const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy' const summaries = propertySummaries(properties).map((property) => expressions.has(property.name) ? { ...property, expression: expressions.get(property.name) } : property) const sketch = typeId === 'Sketcher::SketchObject' ? sketchFromPropertySummaries(name, summaries) : undefined - return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, propertyCount: properties.length, properties: summaries, support, extensions, ...(sketch ? { sketch } : {}) } + return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, ...(nativeObjectTag === undefined ? {} : { nativeObjectTag }), propertyCount: properties.length, properties: summaries, support, extensions, ...(sketch ? { sketch } : {}) } }) return { schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown', @@ -2348,6 +2374,7 @@ const createProxyDocument = (inspection: Omit) return { id: object.name, typeId: object.typeId, + ...(object.nativeObjectTag === undefined ? {} : { nativeObjectTag: object.nativeObjectTag }), properties: [ createProxyProperty('Label', 'Label', object.label), createProxyProperty('TypeId', 'Type', object.typeId), diff --git a/src/facade/geometryRuntime.ts b/src/facade/geometryRuntime.ts index be670d0..fbabf4a 100644 --- a/src/facade/geometryRuntime.ts +++ b/src/facade/geometryRuntime.ts @@ -1324,9 +1324,12 @@ export class BitbybitGeometryRuntime { const stageTransport = input.stages?.length ? { inputs: input.inputs.map((source, index) => ({ inputId: source.inputId ?? `${input.operationId}:input:${index}`, + objectId: source.objectId, role: source.role, stageId: source.stageId, step: stepByObjectId.get(source.objectId)!.text, + objectTag: source.objectTag, + namingEvidence: source.namingEvidence, })), stages: input.stages.map((stage) => ({ stageId: stage.stageId, @@ -1339,12 +1342,12 @@ export class BitbybitGeometryRuntime { ordinal: stage.ordinal, })), } : {} - const captureHistory = (request: Parameters[1]) => nativeHistory.coordinator.capture(nativeHistory.provider, { ...request, ...stageTransport }) - const mapHistoryRecords = (response: Parameters[0], sourceIds: { object: string; tool: string } | Record) => mapNativeOcctHistoryRecords(response, sourceIds, input) - type NativeStageRequest = Pick & Partial> const finalResultObjectId = () => input.stages?.length ? input.stages[input.stages.length - 1].resultObjectId ?? `${input.operationId}:result` : `${input.operationId}:result` + const captureHistory = (request: Parameters[1]) => nativeHistory.coordinator.capture(nativeHistory.provider, { ...request, ...stageTransport, resultObjectId: finalResultObjectId(), resultObjectTag: input.resultObjectTag }) + const mapHistoryRecords = (response: Parameters[0], sourceIds: { object: string; tool: string } | Record) => mapNativeOcctHistoryRecords(response, sourceIds, input) + type NativeStageRequest = Pick & Partial> const namingEvidenceForResponse = (response: NativeOcctHistoryResponse, stageId: string, resultObjectId: string) => response.namingEvidence ? assertNativeNamingEvidence({ ...response.namingEvidence, stageId, resultObjectId }) : createFinalShapeOnlyNamingEvidence(stageId, resultObjectId) @@ -1358,11 +1361,24 @@ export class BitbybitGeometryRuntime { inputs: NativeTopologyHistoryInput['inputs'] request: NativeStageRequest }) => { + const transportInputs = stage.inputs.map((source, index) => ({ + inputId: `${stage.stageId}:input:${index}`, + objectId: source.objectId, + role: index === 0 ? 'object' : 'tool', + stageId: source.stageId, + step: index === 0 ? stage.request.objectStep : stage.request.toolStep ?? stage.request.objectStep, + objectTag: source.objectTag, + namingEvidence: source.namingEvidence, + })) const execution = await nativeHistory.coordinator.capture(nativeHistory.provider, { documentId: input.documentId, documentVersion: input.documentVersion, operationId: stage.stageId, operation: stage.operation, + resultObjectId: stage.resultObjectId, + ...(stage.resultObjectId === finalResultObjectId() && input.resultObjectTag !== undefined ? { resultObjectTag: input.resultObjectTag } : {}), + inputs: transportInputs, + stages: [{ stageId: stage.stageId, operation: stage.operation, inputIds: transportInputs.map(({ inputId }) => inputId), ordinal: stage.ordinal }], ...stage.request, }) if (execution.status !== 'completed' || !execution.response) throw new Error(`Native OCCT ${stage.operation} stage history ${execution.status}.`) @@ -1445,8 +1461,8 @@ export class BitbybitGeometryRuntime { ordinal: 2, sourceIds: { object: firstObjectId, tool: secondObjectId }, inputs: [ - { objectId: firstObjectId, shape: profileInput.shape, stageId: firstStageId }, - { objectId: secondObjectId, shape: profileInput.shape, stageId: secondStageId }, + { objectId: firstObjectId, shape: profileInput.shape, stageId: firstStageId, namingEvidence: first.capture.namingEvidence }, + { objectId: secondObjectId, shape: profileInput.shape, stageId: secondStageId, namingEvidence: second.capture.namingEvidence }, ], request: { objectStep: first.resultStep, toolStep: second.resultStep }, }) @@ -1498,8 +1514,8 @@ export class BitbybitGeometryRuntime { ordinal: 2, sourceIds: { object: toolOneObjectId, tool: toolTwoObjectId }, inputs: [ - { objectId: toolOneObjectId, shape: profileInput.shape, stageId: toolOneStageId }, - { objectId: toolTwoObjectId, shape: profileInput.shape, stageId: toolTwoStageId }, + { objectId: toolOneObjectId, shape: profileInput.shape, stageId: toolOneStageId, namingEvidence: toolOne.capture.namingEvidence }, + { objectId: toolTwoObjectId, shape: profileInput.shape, stageId: toolTwoStageId, namingEvidence: toolTwo.capture.namingEvidence }, ], request: { objectStep: toolOne.resultStep, toolStep: toolTwo.resultStep }, }) @@ -1512,7 +1528,7 @@ export class BitbybitGeometryRuntime { sourceIds: { object: baseInput.objectId, tool: fusedToolObjectId }, inputs: [ baseInput, - { objectId: fusedToolObjectId, shape: profileInput.shape, stageId: fuseStageId }, + { objectId: fusedToolObjectId, shape: profileInput.shape, stageId: fuseStageId, namingEvidence: fusedTool.capture.namingEvidence }, ], request: { objectStep: baseStep, toolStep: fusedTool.resultStep }, }) @@ -1554,7 +1570,7 @@ export class BitbybitGeometryRuntime { resultObjectId: finalResultObjectId(), ordinal: 1, sourceIds: { object: rotatedProfileObjectId, tool: rotatedProfileObjectId }, - inputs: [{ objectId: rotatedProfileObjectId, shape: profileInput.shape, stageId: rotateStageId }], + inputs: [{ objectId: rotatedProfileObjectId, shape: profileInput.shape, stageId: rotateStageId, namingEvidence: rotated.capture.namingEvidence }], request: { objectStep: rotated.resultStep, axisOrigin, direction: parameters.direction, angle: parameters.totalAngle }, }) return withStageCaptures([rotated.capture, revolution.capture]) @@ -1592,7 +1608,7 @@ export class BitbybitGeometryRuntime { resultObjectId: revolutionToolObjectId, ordinal: 1, sourceIds: { object: rotatedProfileObjectId, tool: rotatedProfileObjectId }, - inputs: [{ objectId: rotatedProfileObjectId, shape: profileInput.shape, stageId: rotateStageId }], + inputs: [{ objectId: rotatedProfileObjectId, shape: profileInput.shape, stageId: rotateStageId, namingEvidence: rotated.capture.namingEvidence }], request: { objectStep: rotated.resultStep, axisOrigin, direction: parameters.direction, angle: parameters.totalAngle }, }) const cut = await captureStage({ @@ -1602,7 +1618,7 @@ export class BitbybitGeometryRuntime { resultObjectId: finalResultObjectId(), ordinal: 2, sourceIds: { object: baseInput.objectId, tool: revolutionToolObjectId }, - inputs: [baseInput, { objectId: revolutionToolObjectId, shape: profileInput.shape, stageId: revolutionStageId }], + inputs: [baseInput, { objectId: revolutionToolObjectId, shape: profileInput.shape, stageId: revolutionStageId, namingEvidence: revolution.capture.namingEvidence }], request: { objectStep: baseStep, toolStep: revolution.resultStep }, }) return withStageCaptures([rotated.capture, revolution.capture, cut.capture]) @@ -1639,6 +1655,9 @@ export class BitbybitGeometryRuntime { documentVersion: input.documentVersion, operationId: toolStageId, operation: 'pad', + resultObjectId: toolObjectId, + inputs: [{ inputId: `${toolStageId}:input:0`, objectId: profileInput.objectId, role: 'object', step: profileStep.text, objectTag: profileInput.objectTag, namingEvidence: profileInput.namingEvidence }], + stages: [{ stageId: toolStageId, operation: 'pad', inputIds: [`${toolStageId}:input:0`], ordinal: 0 }], objectStep: profileStep.text, direction: sides[0].direction, }) @@ -1661,6 +1680,13 @@ export class BitbybitGeometryRuntime { documentVersion: input.documentVersion, operationId: cutStageId, operation: 'cut', + resultObjectId, + ...(input.resultObjectTag === undefined ? {} : { resultObjectTag: input.resultObjectTag }), + inputs: [ + { inputId: `${cutStageId}:input:0`, objectId: baseInput.objectId, role: 'object', step: baseStep.text, objectTag: baseInput.objectTag, namingEvidence: baseInput.namingEvidence }, + { inputId: `${cutStageId}:input:1`, objectId: toolObjectId, role: 'tool', stageId: toolStageId, step: toolResponse.resultStep, namingEvidence: toolResponse.namingEvidence }, + ], + stages: [{ stageId: cutStageId, operation: 'cut', inputIds: [`${cutStageId}:input:0`, `${cutStageId}:input:1`], ordinal: 1 }], objectStep: baseStep.text, toolStep: toolResponse.resultStep, }) @@ -1745,6 +1771,9 @@ export class BitbybitGeometryRuntime { documentVersion: input.documentVersion, operationId: toolStageId, operation: 'revolution', + resultObjectId: toolObjectId, + inputs: [{ inputId: `${toolStageId}:input:0`, objectId: profileInput.objectId, role: 'object', step: profileStep.text, objectTag: profileInput.objectTag, namingEvidence: profileInput.namingEvidence }], + stages: [{ stageId: toolStageId, operation: 'revolution', inputIds: [`${toolStageId}:input:0`], ordinal: 0 }], objectStep: profileStep.text, axisOrigin, direction: sides[0].direction, @@ -1769,6 +1798,13 @@ export class BitbybitGeometryRuntime { documentVersion: input.documentVersion, operationId: cutStageId, operation: 'cut', + resultObjectId, + ...(input.resultObjectTag === undefined ? {} : { resultObjectTag: input.resultObjectTag }), + inputs: [ + { inputId: `${cutStageId}:input:0`, objectId: baseInput.objectId, role: 'object', step: baseStep.text, objectTag: baseInput.objectTag, namingEvidence: baseInput.namingEvidence }, + { inputId: `${cutStageId}:input:1`, objectId: toolObjectId, role: 'tool', stageId: toolStageId, step: toolResponse.resultStep, namingEvidence: toolResponse.namingEvidence }, + ], + stages: [{ stageId: cutStageId, operation: 'cut', inputIds: [`${cutStageId}:input:0`, `${cutStageId}:input:1`], ordinal: 1 }], objectStep: baseStep.text, toolStep: toolResponse.resultStep, }) @@ -1909,6 +1945,13 @@ export class BitbybitGeometryRuntime { documentVersion: input.documentVersion, operationId: cutStageId, operation: 'cut', + resultObjectId, + ...(input.resultObjectTag === undefined ? {} : { resultObjectTag: input.resultObjectTag }), + inputs: [ + { inputId: `${cutStageId}:input:0`, objectId: baseInput.objectId, role: 'object', step: baseStep.text, objectTag: baseInput.objectTag, namingEvidence: baseInput.namingEvidence }, + { inputId: `${cutStageId}:input:1`, objectId: toolObjectId, role: 'tool', stageId: toolStageId, step: toolStep.text, namingEvidence: createFinalShapeOnlyNamingEvidence(toolStageId, toolObjectId, 'Hole tool was synthesized by the geometry runtime; provider did not capture its builder naming evidence.') }, + ], + stages: [{ stageId: cutStageId, operation: 'cut', inputIds: [`${cutStageId}:input:0`, `${cutStageId}:input:1`], ordinal: 1 }], objectStep: baseStep.text, toolStep: toolStep.text, }) @@ -2050,6 +2093,7 @@ export class BitbybitGeometryRuntime { let previousStep = baseStep.text let previousObjectId = baseInput.objectId let previousStageId: string | undefined + let previousNamingEvidence = baseInput.namingEvidence const declaredResultObjectId = input.stages?.length ? input.stages[input.stages.length - 1].resultObjectId : undefined for (let index = 0; index < input.transforms.length; index += 1) { const step = input.transforms[index] @@ -2062,6 +2106,10 @@ export class BitbybitGeometryRuntime { documentVersion: input.documentVersion, operationId: `${input.operationId}:native-stage:${index}`, operation, + resultObjectId, + ...(index === input.transforms.length - 1 && input.resultObjectTag !== undefined ? { resultObjectTag: input.resultObjectTag } : {}), + inputs: [{ inputId: `${stageId}:input:0`, objectId: previousObjectId, role: 'object', stageId: previousStageId, step: previousStep, ...(index === 0 && baseInput.objectTag !== undefined ? { objectTag: baseInput.objectTag } : {}), namingEvidence: previousNamingEvidence }], + stages: [{ stageId, operation, inputIds: [`${stageId}:input:0`], ordinal: index }], objectStep: previousStep, ...(step.type === 'linear' ? { direction: step.direction } : {}), ...(step.type === 'polar' ? { axisOrigin: step.axisOrigin, direction: step.direction, angle: step.angle } : {}), @@ -2085,6 +2133,7 @@ export class BitbybitGeometryRuntime { previousStep = response.resultStep previousObjectId = resultObjectId previousStageId = stageId + previousNamingEvidence = namingEvidenceForResponse(response, stageId, resultObjectId) } const records = captures[captures.length - 1].records as NativeTopologyHistoryRecords Object.defineProperty(records, 'stageCaptures', { value: captures, enumerable: false, configurable: false, writable: false }) diff --git a/src/facade/index.ts b/src/facade/index.ts index d3f0d8f..b5a72dc 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -81,7 +81,7 @@ export { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, nativeNamingAbiCapabilities, native export type { NativeNamingAbiCapabilities, NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryInputTransport, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest, NativeOcctHistoryStageTransport } from './nativeHistoryProtocol' export { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest, FREECAD_PRIVATE_NAMING_ABI_VERSION, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi' export type { FreeCadPrivateNamingAbiDescriptor, FreeCadPrivateNamingAbiProbe, FreeCadPrivateNamingAbiRequest, NativeFreeCadNamingAbiModule } from './nativeNamingAbi' -export { assertNativeNamingEvidence, createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, hasNativeMappedNameEvidence, validateNativeNamingEvidence } from './nativeNamingEvidence' +export { assertNativeNamingEvidence, cloneNativeNamingEvidence, createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, hasNativeMappedNameEvidence, validateNativeNamingEvidence } from './nativeNamingEvidence' export type { NativeMappedNameRef, NativeMappedNameRelation, NativeNamingEvidenceIssue, NativeNamingEvidenceReport, NativeNamingEvidenceStatus, NativeStageNamingEvidence } from './nativeNamingEvidence' export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient' export type { NativeOcctHistoryWorkerOptions } from './nativeHistoryWorkerClient' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 3422db0..649b2c5 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -390,7 +390,7 @@ const expressionReferences = (expression: string): string[] => { const createDocumentFromTemplate = (template: DocumentTemplate): DocumentSnapshot => { const tree = template.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) - const objects = tree.map(createObjectSnapshot) + const objects = tree.map((item, index) => ({ ...createObjectSnapshot(item), nativeObjectTag: index + 1 })) const document: DocumentSnapshot = { id: template.id, label: template.label, version: template.version, dirty: template.dirty, readOnly: template.readOnly, units: template.units, tree, objects } document.dependencies = collectDependencyEdges(document) document.recompute = createRecomputeSnapshot(objects.map((object) => object.id)) @@ -799,6 +799,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW tree.push(item) } const objectSnapshot = createObjectSnapshot(item) + objectSnapshot.nativeObjectTag = document.objects.reduce((maximum, object) => Math.max(maximum, object.nativeObjectTag ?? 0), 0) + 1 if (commandId === 'create-body') { const tip = objectSnapshot.properties.find((property) => property.name === 'Tip') if (tip) tip.value = null diff --git a/src/facade/nativeHistoryProtocol.ts b/src/facade/nativeHistoryProtocol.ts index 43a0521..03118ff 100644 --- a/src/facade/nativeHistoryProtocol.ts +++ b/src/facade/nativeHistoryProtocol.ts @@ -1,5 +1,6 @@ import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctHistoryStepProvider, NativeOcctMultiTransformStep } from './nativeHistoryProvider' import { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi' +import type { NativeStageNamingEvidence } from './nativeNamingEvidence' export const NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1 as const @@ -61,6 +62,8 @@ export type NativeOcctHistoryRequest = { documentId: string documentVersion: number operationId: string + /** Document object produced by this native stage; distinct from the request operation ID. */ + resultObjectId?: string operation: NativeOcctHistoryOperation transformKind?: 'linear' | 'polar' | 'mirrored' transforms?: NativeOcctMultiTransformStep[] @@ -70,6 +73,7 @@ export type NativeOcctHistoryRequest = { inputs?: NativeOcctHistoryInputTransport[] stages?: NativeOcctHistoryStageTransport[] resultStepByStage?: Record + resultObjectTag?: number direction?: [number, number, number] axisOrigin?: [number, number, number] angle?: number @@ -87,9 +91,13 @@ export type NativeOcctHistoryRequest = { export type NativeOcctHistoryInputTransport = { inputId: string + /** Stable document object identity represented by this transport input. */ + objectId?: string role?: string stageId?: string step: string + objectTag?: number + namingEvidence?: NativeStageNamingEvidence } export type NativeOcctHistoryStageTransport = { @@ -129,6 +137,7 @@ const booleanOperations: readonly NativeOcctHistoryOperation[] = ['fuse', 'cut', const assertRequest = (request: NativeOcctHistoryRequest) => { if (request.protocolVersion !== NATIVE_OCCT_HISTORY_PROTOCOL_VERSION) throw new RangeError(`Unsupported native OCCT history protocol version: ${request.protocolVersion}.`) + if (request.resultObjectId !== undefined && !request.resultObjectId.trim()) throw new TypeError('Native OCCT history resultObjectId must be non-empty when provided.') if (!request.requestId.trim() || !request.documentId.trim() || !request.operationId.trim()) throw new TypeError('Native OCCT history request IDs must be non-empty strings.') if (!Number.isSafeInteger(request.documentVersion) || request.documentVersion < 0) throw new RangeError('Native OCCT history documentVersion must be a non-negative integer.') if (!['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'].includes(request.operation)) throw new RangeError(`Unsupported native OCCT history operation: ${String(request.operation)}.`) @@ -162,6 +171,11 @@ const assertRequest = (request: NativeOcctHistoryRequest) => { if (!step.startsWith('ISO-10303-21;')) throw new TypeError(`Native OCCT result step for '${stageId}' requires STEP text.`) } } + if (request.resultObjectTag !== undefined && (!Number.isSafeInteger(request.resultObjectTag) || request.resultObjectTag <= 0)) throw new RangeError('Native OCCT history resultObjectTag must be a positive safe integer.') + for (const input of request.inputs ?? []) { + if (input.objectId !== undefined && !input.objectId.trim()) throw new TypeError(`Native OCCT history input ${input.inputId} objectId must be non-empty when provided.`) + if (input.objectTag !== undefined && (!Number.isSafeInteger(input.objectTag) || input.objectTag <= 0)) throw new RangeError(`Native OCCT history input ${input.inputId} objectTag must be a positive safe integer.`) + } if (booleanOperations.includes(request.operation)) { if (!request.toolStep?.startsWith('ISO-10303-21;')) throw new TypeError('Native Boolean history transport requires object and tool STEP text.') } else if (request.operation === 'pocket') { @@ -359,8 +373,9 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide operationId: request.operationId, operation: request.operation, stageId, - resultObjectId: request.operationId, - inputs: (request.inputs ?? [{ inputId: 'object', role: 'object', step: request.objectStep }, ...(request.toolStep ? [{ inputId: 'tool', role: 'tool', step: request.toolStep }] : [])]).map(({ inputId, role, stageId: inputStageId, step }) => ({ inputId, step, ...(role ? { role } : {}), ...(inputStageId ? { stageId: inputStageId } : {}) })), + resultObjectId: request.resultObjectId ?? request.operationId, + ...(request.resultObjectTag === undefined ? {} : { resultObjectTag: request.resultObjectTag }), + inputs: (request.inputs ?? [{ inputId: 'object', role: 'object', step: request.objectStep }, ...(request.toolStep ? [{ inputId: 'tool', role: 'tool', step: request.toolStep }] : [])]).map(({ inputId, objectId, role, stageId: inputStageId, step, objectTag, namingEvidence }) => ({ inputId, step, ...(objectId ? { objectId } : {}), ...(role ? { role } : {}), ...(inputStageId ? { stageId: inputStageId } : {}), ...(objectTag === undefined ? {} : { objectTag }), ...(namingEvidence ? { namingEvidence } : {}) })), stages: orderedStages.map((stage) => ({ ...stage, inputIds: [...stage.inputIds] })), ...(stagedHistory.resultStep ? { resultStep: stagedHistory.resultStep } : {}), ...(stagedHistory.resultBrep ? { resultBrep: stagedHistory.resultBrep } : {}), diff --git a/src/facade/nativeHistoryWorkerEntry.ts b/src/facade/nativeHistoryWorkerEntry.ts index 85f1400..b18d86b 100644 --- a/src/facade/nativeHistoryWorkerEntry.ts +++ b/src/facade/nativeHistoryWorkerEntry.ts @@ -94,8 +94,9 @@ scope.onmessage = ({ data }: MessageEvent) => { operationId: request.operationId, operation: request.operation, stageId, - resultObjectId: request.operationId, - inputs: (request.inputs ?? [{ inputId: 'object', role: 'object', step: request.objectStep }, ...(request.toolStep ? [{ inputId: 'tool', role: 'tool', step: request.toolStep }] : [])]).map(({ inputId, role, stageId: inputStageId, step }) => ({ inputId, step, ...(role ? { role } : {}), ...(inputStageId ? { stageId: inputStageId } : {}) })), + resultObjectId: request.resultObjectId ?? request.operationId, + ...(request.resultObjectTag === undefined ? {} : { resultObjectTag: request.resultObjectTag }), + inputs: (request.inputs ?? [{ inputId: 'object', role: 'object', step: request.objectStep }, ...(request.toolStep ? [{ inputId: 'tool', role: 'tool', step: request.toolStep }] : [])]).map(({ inputId, objectId, role, stageId: inputStageId, step, objectTag, namingEvidence }) => ({ inputId, step, ...(objectId ? { objectId } : {}), ...(role ? { role } : {}), ...(inputStageId ? { stageId: inputStageId } : {}), ...(objectTag === undefined ? {} : { objectTag }), ...(namingEvidence ? { namingEvidence } : {}) })), stages: orderedStages.map((stage) => ({ ...stage, inputIds: [...stage.inputIds] })), ...(history.resultStep ? { resultStep: history.resultStep } : {}), ...(history.resultBrep ? { resultBrep: history.resultBrep } : {}), diff --git a/src/facade/nativeNamingAbi.ts b/src/facade/nativeNamingAbi.ts index 15c4461..5ceaa31 100644 --- a/src/facade/nativeNamingAbi.ts +++ b/src/facade/nativeNamingAbi.ts @@ -1,5 +1,5 @@ import { migrateElementMap2Schema, validateElementMap2 } from './elementMap2' -import { assertNativeNamingEvidence, type NativeStageNamingEvidence } from './nativeNamingEvidence' +import { assertNativeNamingEvidence, cloneNativeNamingEvidence, type NativeStageNamingEvidence } from './nativeNamingEvidence' import { migrateStringHasherSchema, validateElementMap2StringHasherEvidence, validateStringHasherTable } from './stringHasher' import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse } from './nativeHistoryProvider' @@ -42,7 +42,8 @@ export type FreeCadPrivateNamingAbiRequest = { operation: NativeOcctHistoryOperation stageId: string resultObjectId: string - inputs: Array<{ inputId: string; role?: string; stageId?: string; step: string }> + resultObjectTag?: number + inputs: Array<{ inputId: string; objectId?: string; role?: string; stageId?: string; step: string; objectTag?: number; namingEvidence?: NativeStageNamingEvidence }> stages: Array<{ stageId: string; operation?: NativeOcctHistoryOperation; inputIds: string[]; resultStageId?: string; ordinal: number }> resultStep?: string resultBrep?: string @@ -91,12 +92,15 @@ export const probeFreeCadPrivateNamingAbi = (module: NativeFreeCadNamingAbiModul export const createFreeCadPrivateNamingAbiRequest = (input: Omit): FreeCadPrivateNamingAbiRequest => { if (!input.requestId.trim() || !input.documentId.trim() || !input.operationId.trim() || !input.stageId.trim() || !input.resultObjectId.trim()) throw new TypeError('FreeCAD naming ABI request IDs must be non-empty.') if (!Number.isSafeInteger(input.documentVersion) || input.documentVersion < 0) throw new RangeError('FreeCAD naming ABI documentVersion must be a non-negative integer.') + if (input.resultObjectTag !== undefined && (!Number.isSafeInteger(input.resultObjectTag) || input.resultObjectTag <= 0)) throw new RangeError('FreeCAD naming ABI resultObjectTag must be a positive safe integer.') return { schemaVersion: 1, ...input, inputs: input.inputs.map((entry) => { if (!entry.inputId.trim() || !entry.step.trim()) throw new TypeError('FreeCAD naming ABI inputs require non-empty inputId and STEP text.') - return { ...entry } + if (entry.objectId !== undefined && !entry.objectId.trim()) throw new TypeError(`FreeCAD naming ABI input ${entry.inputId} objectId must be non-empty when provided.`) + if (entry.objectTag !== undefined && (!Number.isSafeInteger(entry.objectTag) || entry.objectTag <= 0)) throw new RangeError(`FreeCAD naming ABI input ${entry.inputId} objectTag must be a positive safe integer.`) + return { ...entry, ...(entry.namingEvidence ? { namingEvidence: cloneNativeNamingEvidence(assertNativeNamingEvidence(entry.namingEvidence)) } : {}) } }), stages: input.stages.map((entry) => ({ ...entry, inputIds: [...entry.inputIds] })), ...(input.resultStepByStage ? { resultStepByStage: { ...input.resultStepByStage } } : {}), diff --git a/src/facade/nativeNamingEvidence.ts b/src/facade/nativeNamingEvidence.ts index e531997..814311e 100644 --- a/src/facade/nativeNamingEvidence.ts +++ b/src/facade/nativeNamingEvidence.ts @@ -1,5 +1,5 @@ -import type { AnyElementMap2Document, ElementMap2MappedNameReference } from './elementMap2' -import { migrateStringHasherSchema, validateStringHasherTable, type AnyStringHasherTable } from './stringHasher' +import { cloneElementMap2, migrateElementMap2Schema, type AnyElementMap2Document, type ElementMap2MappedNameReference } from './elementMap2' +import { cloneStringHasherTable, migrateStringHasherSchema, validateStringHasherTable, type AnyStringHasherTable } from './stringHasher' export type NativeMappedNameRelation = 'preserved' | 'modified' | 'generated' | 'deleted' | 'ambiguous' @@ -119,6 +119,22 @@ export const assertNativeNamingEvidence = ( return evidence } +export const cloneNativeNamingEvidence = (evidence: NativeStageNamingEvidence): NativeStageNamingEvidence => ({ + ...evidence, + mappedNames: evidence.mappedNames?.map((mapped) => ({ + ...mapped, + reference: { + ...mapped.reference, + indexedName: mapped.reference.indexedName ? { ...mapped.reference.indexedName } : undefined, + stringIds: mapped.reference.stringIds ? [...mapped.reference.stringIds] : undefined, + }, + sourceRefs: mapped.sourceRefs?.map((source) => ({ ...source })), + candidates: mapped.candidates?.map((candidate) => ({ ...candidate })), + })), + stringHasher: evidence.stringHasher ? cloneStringHasherTable(evidence.stringHasher) : undefined, + elementMap2: evidence.elementMap2 ? cloneElementMap2(migrateElementMap2Schema(evidence.elementMap2)) : undefined, +}) + export const createFinalShapeOnlyNamingEvidence = (stageId: string, resultObjectId: string, reason = 'Native provider returned final Shape without MappedNameRef/StringHasher evidence.'): NativeStageNamingEvidence => ({ schemaVersion: 1, stageId, diff --git a/src/facade/persistenceWorker.ts b/src/facade/persistenceWorker.ts index f1c96cf..2eafb2f 100644 --- a/src/facade/persistenceWorker.ts +++ b/src/facade/persistenceWorker.ts @@ -90,6 +90,13 @@ const initialize = async (requestedPath?: string, migrationFailureVersion?: numb const saveDocument = (document: DocumentSnapshot) => { if (!database) throw new Error('Persistence database is not initialized.') + const nativeObjectTags = new Set() + for (const object of document.objects) { + if (object.nativeObjectTag === undefined) continue + if (!Number.isSafeInteger(object.nativeObjectTag) || object.nativeObjectTag <= 0) throw new RangeError(`Object ${object.id} nativeObjectTag must be a positive safe integer.`) + if (nativeObjectTags.has(object.nativeObjectTag)) throw new RangeError(`nativeObjectTag ${object.nativeObjectTag} is assigned to more than one object.`) + nativeObjectTags.add(object.nativeObjectTag) + } const now = Date.now() database.exec('BEGIN;') try { @@ -98,7 +105,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, 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] }) }) + 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, native_object_tag) 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, object?.nativeObjectTag ?? 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] }) @@ -132,7 +139,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, topology_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, native_object_tag 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() @@ -142,11 +149,29 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => { propertiesByObject.set(String(propertyRow.object_id), properties) } const tree: ModelTreeItem[] = objects.map((object) => ({ id: String(object.id), label: String(object.label), type: String(object.object_type) as ModelTreeItem['type'], state: object.state ? String(object.state) as ModelTreeItem['state'] : undefined, detail: object.detail ? String(object.detail) : undefined, children: object.children_json ? JSON.parse(String(object.children_json)) as string[] : undefined })) + const usedNativeObjectTags = new Set() + const persistedNativeObjectTags = new Map() + for (const object of objects) { + if (object.native_object_tag === null || object.native_object_tag === undefined) continue + const nativeObjectTag = Number(object.native_object_tag) + if (!Number.isSafeInteger(nativeObjectTag) || nativeObjectTag <= 0) throw new Error(`Persisted object ${String(object.id)} has an invalid native object tag.`) + if (usedNativeObjectTags.has(nativeObjectTag)) throw new Error(`Persisted native object tag ${nativeObjectTag} is duplicated.`) + usedNativeObjectTags.add(nativeObjectTag) + persistedNativeObjectTags.set(String(object.id), nativeObjectTag) + } + let nextNativeObjectTag = 1 const objectSnapshots: DocumentObjectSnapshot[] = tree.map((item) => { 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, topology: row?.topology_json ? JSON.parse(String(row.topology_json)) : undefined } + let nativeObjectTag = persistedNativeObjectTags.get(item.id) + if (nativeObjectTag === undefined) { + while (usedNativeObjectTags.has(nextNativeObjectTag)) nextNativeObjectTag += 1 + nativeObjectTag = nextNativeObjectTag + usedNativeObjectTags.add(nativeObjectTag) + nextNativeObjectTag += 1 + } + return { id: item.id, typeId: typeof typeId === 'string' ? typeId : item.type, nativeObjectTag, 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 9c4e11c..b2e208d 100644 --- a/src/facade/projectSchema.ts +++ b/src/facade/projectSchema.ts @@ -1,4 +1,4 @@ -export const PROJECT_SCHEMA_VERSION = 6 +export const PROJECT_SCHEMA_VERSION = 7 export type ProjectSchemaMigration = { version: number; sql: string } @@ -141,4 +141,5 @@ export const PROJECT_SCHEMA_MIGRATIONS = [ { 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;' }, + { version: 7, sql: 'ALTER TABLE objects ADD COLUMN native_object_tag INTEGER;' }, ] as const diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts index d3cf852..b6a0430 100644 --- a/src/facade/recomputeEngine.ts +++ b/src/facade/recomputeEngine.ts @@ -580,8 +580,16 @@ const topologyForObject = async ( documentVersion: context.documentVersion, operationId, operation: nativeOperation, - inputs: orderedNativeInputs.map(({ source, shape: inputShape }, index) => ({ objectId: source.id, shape: inputShape, inputId: `${object.id}:input:${index}`, role: index === 0 ? 'object' : 'tool' })), + inputs: orderedNativeInputs.map(({ source, shape: inputShape }, index) => ({ + objectId: source.id, + shape: inputShape, + inputId: `${object.id}:input:${index}`, + role: index === 0 ? 'object' : 'tool', + ...(source.nativeObjectTag === undefined ? {} : { objectTag: source.nativeObjectTag }), + namingEvidence: source.topology?.history?.namingEvidence?.at(-1), + })), stages: [{ stageId: `${object.id}:stage:0`, operation: nativeOperation, inputObjectIds: orderedNativeInputs.map(({ source }) => source.id), resultObjectId: object.id, ordinal: 0 }], + ...(object.nativeObjectTag === undefined ? {} : { resultObjectTag: object.nativeObjectTag }), ...(nativeOperation === 'fillet' ? { radius: numericProperty('Radius', 1) } : {}), ...(nativeOperation === 'chamfer' ? { distance: numericProperty('Size', numericProperty('Distance', 1)) } : {}), ...(nativeOperation === 'hole' ? { diff --git a/src/facade/types.ts b/src/facade/types.ts index e876aff..8a8fd9e 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -108,6 +108,8 @@ export type ObjectPropertySnapshot = { export type DocumentObjectSnapshot = { id: string typeId: string + /** Stable positive App::DocumentObject tag persisted by FreeCAD's Document.xml. */ + nativeObjectTag?: number properties: ObjectPropertySnapshot[] sketch?: SketchSnapshot topology?: ObjectTopologySnapshot @@ -407,8 +409,10 @@ export type NativeTopologyHistoryInput = GeometryDocumentContext & { ruled?: boolean offset?: number joinType?: 'Arc' | 'Intersection' - inputs: Array<{ objectId: string; shape: ShapeHandle; inputId?: string; role?: string; stageId?: string }> + inputs: Array<{ objectId: string; shape: ShapeHandle; inputId?: string; role?: string; stageId?: string; objectTag?: number; namingEvidence?: NativeStageNamingEvidence }> stages?: NativeTopologyHistoryStage[] + /** Stable FreeCAD owner tag used when encoding the result ElementMap. */ + resultObjectTag?: number result: ShapeHandle } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index b568df2..5cb4847 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -259,8 +259,8 @@ test('viewport box selection distinguishes enclosed window objects from crossing }) test('project schema is versioned and covers the FreeCAD document graph', () => { - assert.equal(PROJECT_SCHEMA_VERSION, 6) - assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5, 6]) + assert.equal(PROJECT_SCHEMA_VERSION, 7) + assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5, 6, 7]) 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/) }) @@ -719,7 +719,7 @@ test('versioned FreeCAD private naming ABI is capability-gated and attaches vali freecadNamingCapabilitiesJson: () => JSON.stringify({ schemaVersion: 1, freecadVersion: '1.1.1', sourceCommit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d', mappedNameRef: true, stringHasher: true, elementMap2: true, operations: ['cut'] }), freecadNamingEvidenceJson: (requestJson: string) => { capturedRequest = JSON.parse(requestJson) as Record - return JSON.stringify({ schemaVersion: 1, stageId: 'cut-native:stage:0', resultObjectId: 'cut-native', status: 'native-evidence', mappedNames: [{ kind: 'edge', resultIndex: 0, resultPersistentId: 'edge-native-0', relation: 'modified', reference: { name: 'Edge1', stringIds: [0x36] }, sourceRefs: [{ objectId: 'object', persistentId: 'Edge1' }] }], stringHasher, elementMap2 }) + return JSON.stringify({ schemaVersion: 1, stageId: capturedRequest.stageId, resultObjectId: capturedRequest.resultObjectId, status: 'native-evidence', mappedNames: [{ kind: 'edge', resultIndex: 0, resultPersistentId: 'edge-native-0', relation: 'modified', reference: { name: 'Edge1', stringIds: [0x36] }, sourceRefs: [{ objectId: 'object', persistentId: 'Edge1' }] }], stringHasher, elementMap2 }) }, } assert.deepEqual(probeFreeCadPrivateNamingAbi(module).availability, 'available') @@ -731,6 +731,16 @@ test('versioned FreeCAD private naming ABI is capability-gated and attaches vali assert.equal(capturedRequest?.operation, 'cut') assert.equal((capturedRequest?.history as { records: unknown[] }).records.length, 1) + const inputEvidence = createFinalShapeOnlyNamingEvidence('source:stage:0', 'source') + await provider.capture({ protocolVersion: 1, requestId: 'naming-tags', documentId: 'doc', documentVersion: 2, operationId: 'cut-native', resultObjectId: 'cut-result', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool', resultObjectTag: 23, inputs: [{ inputId: 'object', objectId: 'base', role: 'object', step: 'ISO-10303-21; object', objectTag: 17, namingEvidence: inputEvidence }, { inputId: 'tool', objectId: 'tool', role: 'tool', step: 'ISO-10303-21; tool', objectTag: 19 }] }, new AbortController().signal) + assert.equal(capturedRequest?.resultObjectId, 'cut-result') + assert.equal(capturedRequest?.resultObjectTag, 23) + assert.equal((capturedRequest?.inputs as Array<{ objectTag?: number; namingEvidence?: { stageId: string } }>)[0].objectTag, 17) + assert.equal((capturedRequest?.inputs as Array<{ objectId?: string }>)[0].objectId, 'base') + assert.equal((capturedRequest?.inputs as Array<{ namingEvidence?: { stageId: string } }>)[0].namingEvidence?.stageId, 'source:stage:0') + assert.notEqual((capturedRequest?.inputs as Array<{ namingEvidence?: unknown }>)[0].namingEvidence, inputEvidence) + await assert.rejects(() => provider.capture({ protocolVersion: 1, requestId: 'naming-invalid-tag', documentId: 'doc', documentVersion: 2, operationId: 'cut-native', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool', resultObjectTag: 0 }, new AbortController().signal), /positive safe integer/) + const invalid = { ...module, freecadNamingCapabilitiesJson: () => JSON.stringify({ schemaVersion: 1, freecadVersion: '1.1.1', sourceCommit: 'wrong', mappedNameRef: true, stringHasher: true, elementMap2: true, operations: ['cut'] }) } assert.equal(probeFreeCadPrivateNamingAbi(invalid).availability, 'unavailable') }) @@ -4367,6 +4377,9 @@ test('FCStd metadata writer rejects ambiguous object identity', () => { const document = createMockFacade().app.document.getActive() assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [...document.objects, { ...document.objects[0] }] }), /unique object ids/) assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], id: ' ' }] }), /non-empty object ids/) + assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: document.objects.slice(0, 2).map((object) => ({ ...object, nativeObjectTag: 9 })) }), /assigned to more than one object/) + const duplicateNativeTag = zipSync({ 'Document.xml': strToU8('') }) + assert.throws(() => inspectFcstdArchive(duplicateNativeTag), /native id 4 is duplicated/) }) test('FCStd inspection inventories object extensions without instantiating them', () => { @@ -4379,7 +4392,7 @@ test('FCStd metadata writer round-trips Document and GuiDocument while preservin const document = { id: 'fcstd-writer-doc', label: 'Writer fixture', version: 1, dirty: false, readOnly: false, units: 'mm', tree: [{ id: 'body', label: 'Body', type: 'body' as const, state: 'active' as const }], - objects: [{ id: 'body', typeId: 'PartDesign::Body', properties: [ + objects: [{ id: 'body', typeId: 'PartDesign::Body', nativeObjectTag: 37, properties: [ { name: 'Label', label: 'Label', type: 'App::PropertyString', value: 'Body', expression: 'Spreadsheet.Width * 2', recompute: false }, { name: 'Length', label: 'Length', type: 'App::PropertyLength', value: 12.5 }, { name: 'Visible', label: 'Visible', type: 'App::PropertyBool', value: true }, @@ -4391,6 +4404,8 @@ test('FCStd metadata writer round-trips Document and GuiDocument while preservin const inspection = inspectFcstdArchive(archive) assert.equal(inspection.label, 'Writer fixture') assert.equal(inspection.objects[0].typeId, 'PartDesign::Body') + assert.equal(inspection.objects[0].nativeObjectTag, 37) + assert.equal(inspection.proxyDocument.objects[0].nativeObjectTag, 37) assert.equal(inspection.objects[0].properties[0].value, 'Body') assert.equal(inspection.objects[0].properties[0].expression, 'Spreadsheet.Width * 2') assert.equal(inspection.objects[0].properties.find((property) => property.name === 'Length')?.element, 'Float')