From aa607451ad566956069b921b332654ab5d37e209 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Tue, 11 Aug 2026 20:34:23 -0400 Subject: [PATCH] feat: complete supported Sketcher and PartDesign ABI contract --- config/compatibility-matrix.json | 8 +- ...ecad-sketcher-partdesign-abi-contract.json | 99 ++++++ config/freecad-web-exact-parity-plan.json | 6 +- config/release-artifacts.json | 30 +- docs/continuation-status.zh-CN.md | 12 +- docs/freecad-full-parity-plan.zh-CN.md | 4 + package.json | 3 +- scripts/check-facade-boundary.mjs | 2 +- ...eecad-sketcher-partdesign-abi-contract.mjs | 50 +++ src/facade/fcstd.ts | 82 ++++- src/facade/index.ts | 8 +- src/facade/mockFacade.ts | 35 +++ src/facade/nativeHistoryProtocol.ts | 47 ++- src/facade/nativeHistoryProvider.ts | 3 +- src/facade/nativeHistoryWorkerEntry.ts | 43 ++- src/facade/nativeNamingAbi.ts | 136 +++++++++ src/facade/partDesignParameters.ts | 288 ++++++++++++++++++ src/facade/runtimeProfile.ts | 4 +- src/facade/sketcher.ts | 30 +- tests/facade.test.ts | 90 +++++- 20 files changed, 918 insertions(+), 62 deletions(-) create mode 100644 config/freecad-sketcher-partdesign-abi-contract.json create mode 100644 scripts/check-freecad-sketcher-partdesign-abi-contract.mjs create mode 100644 src/facade/nativeNamingAbi.ts create mode 100644 src/facade/partDesignParameters.ts diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 9842474..a7b9621 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -97,7 +97,7 @@ "status": "explicit-runtime-contract", "mappedNameRef": "optional provider evidence; final-shape-only stages are marked and cannot mint FreeCAD tokens", "stringHasher": "optional opaque native table; validated and preserved losslessly", - "exactBlockers": ["browser FreeCAD private token callback ABI", "browser builder naming evidence transport", "non-unique isomorphic source"] + "exactBlockers": ["shipped Worker lacks the FreeCAD-linked private naming ABI implementation", "browser builder naming evidence transport", "non-unique isomorphic source"] } }, "browserWorker": { @@ -124,13 +124,13 @@ "verification": { "level": "experimental", "provider": "FreeCADCmd 1.1.1 native headless Part oracle", "operations": ["locked-source-commit", "reproducible-native-build", "declarative-golden-contract", "part-geometry-replay", "numeric-tolerance-comparison", "execution-plan-validation", "source-inventory-validation", "type-property-inventory-validation"] } }, "systemExactEvaluation": { - "evaluatedAt": "2026-08-10", + "evaluatedAt": "2026-08-11", "exact": false, "featureExactCount": 0, "plan": "config/freecad-web-exact-parity-plan.json", "promotionTask": "EX-REL-01", - "taskStatus": { "completed": 2, "inProgress": 36, "pending": 14, "blocked": 0 }, - "blockers": ["browser FreeCAD private token callback ABI", "browser builder naming evidence transport", "non-unique isomorphic source"] + "taskStatus": { "completed": 2, "inProgress": 37, "pending": 13, "blocked": 0 }, + "blockers": ["shipped Worker lacks the FreeCAD-linked private naming ABI implementation", "browser builder naming evidence transport", "non-unique isomorphic source"] }, "rules": [ "UI status does not imply geometry or file compatibility.", diff --git a/config/freecad-sketcher-partdesign-abi-contract.json b/config/freecad-sketcher-partdesign-abi-contract.json new file mode 100644 index 0000000..64c00ff --- /dev/null +++ b/config/freecad-sketcher-partdesign-abi-contract.json @@ -0,0 +1,99 @@ +{ + "schemaVersion": 1, + "baseline": { + "version": "1.1.1", + "sourceCommit": "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d" + }, + "claim": { + "scope": "supported-web-facade", + "exactFreeCadParity": false, + "reason": "The contract exhausts the Sketcher serialization surface and supported PartDesign facade families; the shipped OCCT-only Worker is not linked with FreeCAD private naming code." + }, + "sketcher": { + "geometryTypes": [ + "point", + "line", + "arc", + "circle", + "ellipse", + "arcEllipse", + "arcHyperbola", + "arcParabola", + "bspline" + ], + "constraintTypes": [ + "coincident", + "horizontal", + "vertical", + "parallel", + "tangent", + "distance", + "distanceX", + "distanceY", + "angle", + "perpendicular", + "radius", + "equal", + "pointOnObject", + "symmetric", + "internalAlignment", + "snellsLaw", + "block", + "diameter", + "weight" + ], + "internalAlignmentTypes": [ + "ellipse-major", + "ellipse-minor", + "ellipse-focus", + "hyperbola-major", + "hyperbola-minor", + "hyperbola-focus", + "parabola-focus", + "parabola-focal-axis", + "bspline-control-point", + "bspline-knot" + ] + }, + "partDesign": { + "families": 21, + "propertySlots": 202, + "semanticPartitions": 72, + "typeIds": [ + "PartDesign::Plane", + "PartDesign::Line", + "PartDesign::Point", + "PartDesign::ShapeBinder", + "PartDesign::Pad", + "PartDesign::Pocket", + "PartDesign::Revolution", + "PartDesign::Groove", + "PartDesign::AdditiveLoft", + "PartDesign::SubtractiveLoft", + "PartDesign::AdditivePipe", + "PartDesign::SubtractivePipe", + "PartDesign::Fillet", + "PartDesign::Chamfer", + "PartDesign::Draft", + "PartDesign::Thickness", + "PartDesign::Mirrored", + "PartDesign::MultiTransform", + "PartDesign::LinearPattern", + "PartDesign::PolarPattern", + "PartDesign::Hole" + ] + }, + "privateNamingAbi": { + "abiVersion": 1, + "transport": "versioned-json-over-embind", + "requiredEvidence": [ + "MappedNameRef", + "StringHasher", + "ElementMap2" + ], + "runtimeProfile": "freecad-private-v1-optional-worker", + "shippedWorkerImplementation": "not-linked", + "fallback": "final-shape-only", + "syntheticTokenGeneration": "forbidden" + } +} diff --git a/config/freecad-web-exact-parity-plan.json b/config/freecad-web-exact-parity-plan.json index 6276779..744e2ae 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": "pending", "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"], "exactBlockedBy": ["FreeCAD private naming callbacks are not exposed 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"], "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"] } ] @@ -52,10 +52,10 @@ "id": "EX04", "title": "Sketcher, Part and PartDesign feature exactness", "tasks": [ - { "id": "EX-SK-01", "title": "Cover every Sketcher geometry, constraint overload and solver classification", "priority": "P0", "status": "in_progress", "dependencies": ["EX-KER-01", "EX-DOC-02"], "deliverables": ["Complete geometry and constraint matrix", "Planegcs result parity", "Reference, redundant and conflict oracle"], "acceptance": ["Geometry, DOF, diagnostics and solver results match every fixture"], "evidence": ["check:freecad-sketcher-constraints", "check:chrome-sketcher-diagnostics"], "exactBlockedBy": ["Arbitrary overload combinations and all failure messages are not exhaustive"] }, + { "id": "EX-SK-01", "title": "Cover every Sketcher geometry, constraint overload and solver classification", "priority": "P0", "status": "in_progress", "dependencies": ["EX-KER-01", "EX-DOC-02"], "deliverables": ["Complete geometry and constraint matrix", "Planegcs result parity", "Reference, redundant and conflict oracle"], "acceptance": ["Geometry, DOF, diagnostics and solver results match every fixture"], "evidence": ["check:freecad-sketcher-constraints", "check:freecad-sketcher-partdesign-abi", "check:chrome-sketcher-diagnostics"], "exactBlockedBy": ["Arbitrary overload combinations and all failure messages are not exhaustive"] }, { "id": "EX-SK-02", "title": "Match Sketcher editing tools, autoconstraints, virtual space and UI lifecycle", "priority": "P0", "status": "in_progress", "dependencies": ["EX-SK-01", "EX-UI-03"], "deliverables": ["Pointer and keyboard editor oracle", "All editing tools", "Task and focus lifecycle"], "acceptance": ["Every tool supports success, failure, cancel, undo and redo with matching selection"], "evidence": ["check:freecad-sketcher-editor", "check:chrome-sketcher-bspline"], "exactBlockedBy": ["The full GUI tool and focus matrix is incomplete"] }, { "id": "EX-PART-01", "title": "Complete all Part primitives, builders, booleans, healing and inspection", "priority": "P0", "status": "in_progress", "dependencies": ["EX-KER-01", "EX-TSN-04"], "deliverables": ["All Part commands and parameters", "Healing and tolerance tools", "Native history for local operations"], "acceptance": ["Success and failure outputs match native Shape, history and diagnostics"], "evidence": ["check:chrome-part-primitives", "check:freecad-golden-fixtures"], "exactBlockedBy": ["Current golden corpus does not enumerate every Part command and option"] }, - { "id": "EX-PD-01", "title": "Complete all PartDesign features, parameters and additive/subtractive combinations", "priority": "P0", "status": "in_progress", "dependencies": ["EX-PART-01", "EX-TSN-04"], "deliverables": ["Complete PartDesign feature matrix", "Body Tip and feature-list semantics", "Per-feature native history and naming"], "acceptance": ["Every feature edit, failure recovery and save-reopen matches FreeCAD"], "evidence": ["check:partdesign-closure", "check:chrome-partdesign-lifecycle"], "exactBlockedBy": ["Feature coverage is broad but private naming and all parameter combinations are not exhaustive"] }, + { "id": "EX-PD-01", "title": "Complete all PartDesign features, parameters and additive/subtractive combinations", "priority": "P0", "status": "in_progress", "dependencies": ["EX-PART-01", "EX-TSN-04"], "deliverables": ["Complete PartDesign feature matrix", "Body Tip and feature-list semantics", "Per-feature native history and naming"], "acceptance": ["Every feature edit, failure recovery and save-reopen matches FreeCAD"], "evidence": ["check:partdesign-closure", "check:freecad-sketcher-partdesign-abi", "check:chrome-partdesign-lifecycle"], "exactBlockedBy": ["The parameter contract covers the supported Facade subset, not every registered PartDesign TypeId or cross-product"] }, { "id": "EX-PD-02", "title": "Complete attachment, datum, ShapeBinder, SubShapeBinder and body workflows", "priority": "P0", "status": "pending", "dependencies": ["EX-PD-01", "EX-DOC-04"], "deliverables": ["All map modes and support rules", "Datum and binder lifecycle", "Multi-body and cross-document corpus"], "acceptance": ["Support migration, visibility, Tip and references remain exact through mutation"], "evidence": ["check:chrome-partdesign-lifecycle", "check:freecad-fcstd-roundtrip"], "exactBlockedBy": ["Complete datum/binder and multi-body oracle is absent"] } ] }, diff --git a/config/release-artifacts.json b/config/release-artifacts.json index 8000c33..101522f 100644 --- a/config/release-artifacts.json +++ b/config/release-artifacts.json @@ -22,20 +22,20 @@ "bytes": 556597, "sha256": "1679307fb0f01e9d7b9f37a081458bf44583e4d1899edf9224b49a1435ce7105" }, - { - "path": "assets/index-BcH6J3tB.js", - "bytes": 1103011, - "sha256": "870d33fae0ceabaf31a8100e65ad7f1afb3f4f869bfc225bb8c722d7771ca917" - }, { "path": "assets/index-BnRqhr3m.css", "bytes": 65976, "sha256": "b8eacf86224d2705e3c27367b3a0b166f4d662a5b8142d34a8abcdeef58753ec" }, { - "path": "assets/nativeHistoryWorkerEntry-D9i46nqJ.js", - "bytes": 4664, - "sha256": "488e45728dd4ccfd65acb82c61266332171129a52e916ecbb7cc2a8ab61d4ed6" + "path": "assets/index-ChdGmY7w.js", + "bytes": 1130165, + "sha256": "c508b701c47d2dea412c96f3a529f99b42bd7b137e647d958b993c3268dfc3e6" + }, + { + "path": "assets/nativeHistoryWorkerEntry-gNsJxM2G.js", + "bytes": 20437, + "sha256": "39c26691ba3275ce5fbb3a43239d5511430bc0222259f0c76e3aa9314f2e87cf" }, { "path": "assets/persistenceWorker-CyD5EUVX.js", @@ -43,9 +43,9 @@ "sha256": "65a24ca0125444e3047f409d3b1c3667321e392a0ca9ddf64df6b728a1544c66" }, { - "path": "assets/planegcsWorkerEntry-BEpPJPEo.js", - "bytes": 26112, - "sha256": "2b5b0a9604aa8181f38b803753ad42e33e6c426a9fd8c1df013ae8762c6bb5c0" + "path": "assets/planegcsWorkerEntry-BeAnOOBp.js", + "bytes": 26910, + "sha256": "c2253b714352c02e543fa97dff1c1e7c64ae3983469d4e5a6b9bf75b78ba3483" }, { "path": "assets/sqlite3-BVKGSWc-.wasm", @@ -330,7 +330,7 @@ { "path": "index.html", "bytes": 678, - "sha256": "07fab5a0e83597c5ca0a0cd2d57625f0134b56c02f2349a35be4e6de23540d33" + "sha256": "ce1c0c151dfb290cff01268efaec2efd0cdc354548ffccb5cb1030b02249870f" }, { "path": "manifest.webmanifest", @@ -380,7 +380,7 @@ { "path": "sw.js", "bytes": 1390, - "sha256": "01593ec533053a9ccdf341ef276ee5c0e1752e6dc30951acdc042c33d60f206c" + "sha256": "a8d35592358f8580b3be765a36897969c63ebfb7c777a71296481e0d8118fad7" }, { "path": "vendor/camotics/camotics-sweep.wasm", @@ -402,7 +402,7 @@ "status": "signed", "algorithm": "Ed25519", "keyId": "bitbybit-local-release-2026", - "payloadSha256": "f1fe903dc9879b0f02d1a4becbd5daf7fb9d7b96ef304f2cc04fc5058d80256e", - "signature": "RY1mpA6T/zWYx1/Z/9Z7A2kpkBbbCbP8TQIIykaFbY2E8qzmlAw0qotNYq3yOP5OfbJqas+BjAXWJeEY7WA4DQ==" + "payloadSha256": "be48e099205c1a86339eb4560e62ed88e12ffd0e2e45e036f3a723abd3bb078a", + "signature": "AAyAo8hzt1I48LaHUArN7lEg11zWrl3EkNKF6zr92x2SZHFhXq/5m8KZ0hqJxNU4INM6bC/laqanCJ9GhRbkCQ==" } } diff --git a/docs/continuation-status.zh-CN.md b/docs/continuation-status.zh-CN.md index a8321c2..df38577 100644 --- a/docs/continuation-status.zh-CN.md +++ b/docs/continuation-status.zh-CN.md @@ -1772,6 +1772,16 @@ FCStd 双向往返新增真实 `Part::Cut` 穿孔文档:`20 x 20 x 10` Box 由 TSN 门不再只读取 Pad/Pocket 两份报告。它现在汇总 16 份 Chrome/WASM 原生 history 报告,逐份校验 provider、operation registry、Worker/WASM、关系计数、结果 Solid、stage input/result/topology、专用 marker 和释放后 `0/0`;19 类 operation 的证据为 `19/19`,缺失为 `0`。Pocket、Revolution、Groove、Hole、MultiTransform 的 17 个显式 stage 继续逐级校验,其他专用 builder 由操作报告和 marker 绑定。`EX-TSN-01` 因此更新为 `completed`,exact 计划计数为 `2 completed / 36 in_progress / 14 pending / 0 blocked`。 -命名 oracle 同时修正了旧的误分类。FreeCAD 运行时 219 个阶段中,74 个派生/操作阶段要求私有 MappedName,现为 `required=74 / complete=74`;145 个基础或支持阶段由原生 API 明确返回 `IndexedName-only`,不再被错误统计为 145 个缺失私有 token。42 个 Part/PartDesign builder 阶段均具有直接 `getElementHistory/getElementMappedName/getElementIndexedName` 证据,结果为 `42/42`、missing `0`。这只闭合锁定 oracle 的证据,不代表浏览器 OCCT 能为任意新模型执行 FreeCAD 私有命名算法:浏览器仍没有 `MappedNameRef/StringHasher` callback ABI,原生 token 仍限于 fixture/oracle,非唯一同构来源仍必须保持 ambiguous。 +命名 oracle 同时修正了旧的误分类。FreeCAD 运行时 219 个阶段中,74 个派生/操作阶段要求私有 MappedName,现为 `required=74 / complete=74`;145 个基础或支持阶段由原生 API 明确返回 `IndexedName-only`,不再被错误统计为 145 个缺失私有 token。42 个 Part/PartDesign builder 阶段均具有直接 `getElementHistory/getElementMappedName/getElementIndexedName` 证据,结果为 `42/42`、missing `0`。这只闭合锁定 oracle 的证据,不代表浏览器 OCCT 能为任意新模型执行 FreeCAD 私有命名算法:当时浏览器尚无 `MappedNameRef/StringHasher` callback ABI,原生 token 仍限于 fixture/oracle,非唯一同构来源仍必须保持 ambiguous。 真实 CI oracle lane 已调整为先重跑核心参数、全部 Part/PartDesign、复合命名和 FCStd oracle,再生成参数报告并执行检查;Chrome lane在所有 `test:chrome-*` 重跑后执行 19-operation TSN 聚合门,避免读取旧报告。当前 `systemExact=false`、`EX-TSN-02=pending`、`EX-TSN-04=pending`、`EX-REL-01=pending`、exact modules `0/34`;剩余工作不得用锁定 fixture 的 token 代替浏览器实时私有命名回调。 + +## 229. 2026-08-11 Sketcher/PartDesign 参数合同与 FreeCAD 私有命名 ABI v1 + +Sketcher 数据模型和 FCStd codec 已覆盖 FreeCAD 1.1.1 的完整原生几何类型表:在 Point/Line/Circle/Arc/Ellipse/B-spline 基础上新增 `GeomArcOfEllipse`、`GeomArcOfHyperbola` 和 `GeomArcOfParabola`,并补齐 InternalAlignment 原生编号 `5/6/7/8/11` 的双曲线主轴/副轴/焦点与抛物线焦点/焦轴。9 类 geometry、19 类 constraint、10 类 InternalAlignment 语义由公开常量锁定;新圆锥曲线和 helper geometry 已通过 FCStd 写出、解析和 Web ID 恢复测试。basic TypeScript solver 对尚未求解的圆锥曲线明确返回 unsupported,不伪装成已求解。 + +新增 `partDesignParameters.ts`,将当前 Facade 支持的 Datum/ShapeBinder、Pad/Pocket、Revolution/Groove、Loft/Pipe、dress-up、transform/pattern 和 Hole 共 21 个 family 建成 202 个属性槽、72 个语义分区的可执行合同。对象创建和属性编辑现在统一检查两侧长度/角度、Midplane 冲突、Up-to-face 引用、自定义向量、Loft section、Pipe spine、Chamfer 模式、pattern occurrence/方向和 Hole cut/thread 组合。经锁定桌面对象探针对账,FreeCAD 实际注册的同名字段进入 FCStd native property 白名单;`AxisLink`、显式方向向量、`ThreadPitch` 等 Web 兼容字段仍按动态属性保存,不冒充原生属性。 + +新增 `FreeCADPrivateNamingABI v1`:三项 Embind 回调分别报告 ABI version、锁定 FreeCAD 1.1.1/commit/operation 能力,并按 JSON 请求返回命名证据。Direct provider 与 Worker 都传输输入 STEP、最终 STEP/BRep、stage DAG 和 OCCT history;回包必须包含原生/歧义状态、MappedNameRef、StringHasher、ElementMap2,并通过 stage/result 上下文、16/32 MiB 上限、StringID、ElementMap2 和两者引用闭包检查。缺回调、版本/提交不符、operation 未声明或证据不合法均不生成 token,继续保留 `final-shape-only`。 + +`config/freecad-sketcher-partdesign-abi-contract.json` 与 `check:freecad-sketcher-partdesign-abi` 已进入默认 Sketcher 门禁,Facade 为 `190/190`,独立合同门、Facade boundary、锁定 FreeCAD 原生 FCStd 门和全量 `verify` 均通过。这里完成的是支持范围内的参数合同、FCStd 表面和私有 ABI/传输/验证层;当前随附的 `native/occt-history` 仍未链接 FreeCAD 私有 C++,所以浏览器实时命名实现仍为 `not-linked`。`EX-TSN-02` 由 `pending` 转为 `in_progress`,机器计划为 `2 completed / 37 in_progress / 13 pending / 0 blocked`;`systemExact=false` 和 promotion fail-closed 状态不变。 diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index bd851d1..8080bc5 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -389,6 +389,10 @@ PartDesign Mirrored 已形成 experimental whole-shape 垂直切片:Facade 验 运行时命名证据必须来自 native provider 的 `MappedNameRef`/`StringHasher` 或逐阶段 builder capture。`NativeStageNamingEvidence` 对缺失证据写出 `final-shape-only`,对只有原始表而没有可重建回调的 FCStd 资源写出 `opaque-preserved`;两者都不能仅凭最终 Shape 生成新 FreeCAD token。跨阶段 ElementMap2 映射保存 `sourceStageId`、来源集合和 `ambiguous` 候选,校验失败直接阻断写回。 +`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。 + +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、以及无唯一同构来源。 ### 21. 全功能 exact 计划(2026-08-09) diff --git a/package.json b/package.json index 8d2cf9f..7d5e64c 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "build:freecad-native": "node scripts/build-freecad-native.mjs", "check:freecad-desktop-oracle": "node scripts/check-freecad-desktop-oracle.mjs", "probe:freecad-sketcher-constraints": "node scripts/run-freecad-sketcher-constraint-oracle.mjs", - "check:freecad-sketcher-constraints": "node scripts/check-freecad-sketcher-constraint-oracle.mjs && node scripts/check-freecad-partdesign-profile-oracle.mjs && node scripts/check-freecad-partdesign-base-oracle.mjs && node scripts/check-freecad-partdesign-loft-oracle.mjs && node scripts/check-freecad-partdesign-dressup-oracle.mjs && node scripts/check-freecad-partdesign-transform-oracle.mjs && node scripts/check-freecad-partdesign-failure-oracle.mjs && node scripts/check-freecad-partdesign-revolution-groove-oracle.mjs && node scripts/check-freecad-part-builders-oracle.mjs && node scripts/check-freecad-sketcher-editor-oracle.mjs", + "check:freecad-sketcher-constraints": "node scripts/check-freecad-sketcher-constraint-oracle.mjs && node scripts/check-freecad-partdesign-profile-oracle.mjs && node scripts/check-freecad-partdesign-base-oracle.mjs && node scripts/check-freecad-partdesign-loft-oracle.mjs && node scripts/check-freecad-partdesign-dressup-oracle.mjs && node scripts/check-freecad-partdesign-transform-oracle.mjs && node scripts/check-freecad-partdesign-failure-oracle.mjs && node scripts/check-freecad-partdesign-revolution-groove-oracle.mjs && node scripts/check-freecad-part-builders-oracle.mjs && node scripts/check-freecad-sketcher-editor-oracle.mjs && npm run check:freecad-sketcher-partdesign-abi", + "check:freecad-sketcher-partdesign-abi": "tsx scripts/check-freecad-sketcher-partdesign-abi-contract.mjs", "probe:freecad-partdesign-profiles": "node scripts/run-freecad-partdesign-profile-oracle.mjs", "check:freecad-partdesign-profiles": "node scripts/check-freecad-partdesign-profile-oracle.mjs", "probe:freecad-partdesign-base": "node scripts/run-freecad-partdesign-base-oracle.mjs", diff --git a/scripts/check-facade-boundary.mjs b/scripts/check-facade-boundary.mjs index a4e4578..6d381b7 100644 --- a/scripts/check-facade-boundary.mjs +++ b/scripts/check-facade-boundary.mjs @@ -36,7 +36,7 @@ const application = await readFile(new URL('App.tsx', sourceRoot), 'utf8') const runtimeProfile = await readFile(new URL('facade/runtimeProfile.ts', sourceRoot), 'utf8') if (/from\s+["']\.\/mockFacade["']/.test(productionFacade)) violations.push('facade/productionFacade.ts: production entry imports mockFacade directly') if (/createMockFacade|facade\/mockFacade/.test(application)) violations.push('App.tsx: production application references the mock Facade') -for (const declaration of ['bitbybit-occt', 'optional-worker', 'not-exposed', 'sqlite-opfs-with-memory-fallback', 'three-webgl2']) { +for (const declaration of ['bitbybit-occt', 'optional-worker', 'freecad-private-v1-optional-worker', 'not-exposed', 'sqlite-opfs-with-memory-fallback', 'three-webgl2']) { if (!runtimeProfile.includes(declaration)) violations.push(`facade/runtimeProfile.ts: missing explicit runtime boundary '${declaration}'`) } diff --git a/scripts/check-freecad-sketcher-partdesign-abi-contract.mjs b/scripts/check-freecad-sketcher-partdesign-abi-contract.mjs new file mode 100644 index 0000000..469b797 --- /dev/null +++ b/scripts/check-freecad-sketcher-partdesign-abi-contract.mjs @@ -0,0 +1,50 @@ +import { readFile } from 'node:fs/promises' +import { + FREECAD_PRIVATE_NAMING_ABI_VERSION, + FREECAD_SKETCHER_CONSTRAINT_TYPES, + FREECAD_SKETCHER_GEOMETRY_TYPES, + FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES, + PARTDESIGN_PARAMETER_SPACE, + createFacadeRuntimeProfile, + partDesignParameterSpaceCoverage, +} from '../src/facade/index.ts' + +const report = JSON.parse(await readFile(new URL('../config/freecad-sketcher-partdesign-abi-contract.json', import.meta.url), 'utf8')) +const failures = [] +const check = (condition, message) => { if (!condition) failures.push(message) } +const same = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected) + +check(report.schemaVersion === 1, 'contract schemaVersion must be 1') +check(report.baseline?.version === '1.1.1', 'contract baseline must lock FreeCAD 1.1.1') +check(report.baseline?.sourceCommit === '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d', 'contract baseline source commit changed') +check(report.claim?.scope === 'supported-web-facade' && report.claim?.exactFreeCadParity === false, 'contract must remain scoped and fail-closed for system exact parity') +check(same(FREECAD_SKETCHER_GEOMETRY_TYPES, report.sketcher?.geometryTypes), 'Sketcher geometry type report is stale') +check(same(FREECAD_SKETCHER_CONSTRAINT_TYPES, report.sketcher?.constraintTypes), 'Sketcher constraint type report is stale') +check(same(FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES, report.sketcher?.internalAlignmentTypes), 'Sketcher internal alignment report is stale') + +const coverage = partDesignParameterSpaceCoverage() +check(coverage.duplicateTypeIds === 0, 'PartDesign parameter space has duplicate TypeIds') +check(coverage.familiesWithoutPartitions.length === 0, 'every PartDesign family must have at least three semantic partitions') +check(coverage.families === report.partDesign?.families, 'PartDesign family count report is stale') +check(coverage.properties === report.partDesign?.propertySlots, 'PartDesign property-slot count report is stale') +check(coverage.partitions === report.partDesign?.semanticPartitions, 'PartDesign semantic-partition count report is stale') +check(same(PARTDESIGN_PARAMETER_SPACE.map((family) => family.typeId), report.partDesign?.typeIds), 'PartDesign TypeId report is stale') +for (const family of PARTDESIGN_PARAMETER_SPACE) { + check(new Set(family.properties).size === family.properties.length, `${family.typeId} has duplicate property declarations`) + check(new Set(family.partitions.map((partition) => partition.id)).size === family.partitions.length, `${family.typeId} has duplicate partition IDs`) + for (const partition of family.partitions) for (const propertyName of Object.keys(partition.values)) check(family.properties.includes(propertyName), `${family.typeId}.${partition.id} uses undeclared property ${propertyName}`) +} + +check(FREECAD_PRIVATE_NAMING_ABI_VERSION === report.privateNamingAbi?.abiVersion, 'FreeCAD private naming ABI version report is stale') +check(createFacadeRuntimeProfile('production').naming.nativeAbi === report.privateNamingAbi?.runtimeProfile, 'production runtime profile does not advertise the optional private ABI') +check(createFacadeRuntimeProfile('mock').naming.nativeAbi === 'not-exposed', 'mock runtime must not advertise the private naming ABI') +check(report.privateNamingAbi?.shippedWorkerImplementation === 'not-linked', 'the contract must not claim a FreeCAD-linked Worker before the artifact exports verified callbacks') +check(report.privateNamingAbi?.syntheticTokenGeneration === 'forbidden', 'synthetic FreeCAD token generation must remain forbidden') + +if (failures.length) { + console.error('FreeCAD Sketcher/PartDesign/private-ABI contract failed:') + failures.forEach((failure) => console.error(`- ${failure}`)) + process.exit(1) +} + +console.log(`freecad-sketcher-partdesign-abi-contract-pass geometry=${FREECAD_SKETCHER_GEOMETRY_TYPES.length} constraints=${FREECAD_SKETCHER_CONSTRAINT_TYPES.length} alignments=${FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES.length} families=${coverage.families} properties=${coverage.properties} partitions=${coverage.partitions} nativeWorker=${report.privateNamingAbi.shippedWorkerImplementation}`) diff --git a/src/facade/fcstd.ts b/src/facade/fcstd.ts index 03b02fd..cbdeda2 100644 --- a/src/facade/fcstd.ts +++ b/src/facade/fcstd.ts @@ -334,6 +334,26 @@ const nativeDataPropertyNames = new Map>([ ['Part::Revolution', new Set(['Source', 'Base', 'Axis', 'AxisLink', 'Angle', 'Symmetric', 'Solid', 'FaceMakerClass'])], ['Part::Loft', new Set(['Sections', 'Solid', 'Ruled', 'Closed', 'MaxDegree', 'Linearize'])], ['Part::Sweep', new Set(['Sections', 'Spine', 'Solid', 'Frenet', 'Transition', 'Linearize'])], + ['PartDesign::Plane', new Set(['MapMode', 'AttachmentOffset'])], + ['PartDesign::Line', new Set(['MapMode', 'AttachmentOffset'])], + ['PartDesign::Point', new Set(['MapMode', 'AttachmentOffset'])], + ['PartDesign::ShapeBinder', new Set(['Support', 'TraceSupport'])], + ['PartDesign::Pad', new Set(['Profile', 'Length', 'Length2', 'Type', 'Type2', 'SideType', 'UpToFace', 'UpToFace2', 'TaperAngle', 'TaperAngle2', 'Reversed', 'Midplane', 'ReferenceAxis', 'AlongSketchNormal', 'UseCustomVector', 'Direction', 'Offset', 'Offset2'])], + ['PartDesign::Pocket', new Set(['Profile', 'Length', 'Length2', 'Type', 'Type2', 'SideType', 'UpToFace', 'UpToFace2', 'TaperAngle', 'TaperAngle2', 'Reversed', 'Midplane', 'ReferenceAxis', 'AlongSketchNormal', 'UseCustomVector', 'Direction', 'Offset', 'Offset2'])], + ['PartDesign::Revolution', new Set(['Profile', 'Angle', 'Angle2', 'Type', 'UpToFace', 'ReferenceAxis', 'Axis', 'Midplane', 'Reversed'])], + ['PartDesign::Groove', new Set(['Base', 'Profile', 'Angle', 'Angle2', 'Type', 'UpToFace', 'ReferenceAxis', 'Axis', 'Midplane', 'Reversed'])], + ['PartDesign::AdditiveLoft', new Set(['Profile', 'Sections', 'Ruled', 'Closed'])], + ['PartDesign::SubtractiveLoft', new Set(['Profile', 'Sections', 'Ruled', 'Closed'])], + ['PartDesign::AdditivePipe', new Set(['Profile', 'Spine', 'Transition', 'Mode', 'Transformation'])], + ['PartDesign::SubtractivePipe', new Set(['Profile', 'Spine', 'Transition', 'Mode', 'Transformation'])], + ['PartDesign::Fillet', new Set(['Base', 'Radius', 'UseAllEdges'])], + ['PartDesign::Chamfer', new Set(['Base', 'Size', 'Size2', 'Angle', 'ChamferType', 'FlipDirection', 'UseAllEdges'])], + ['PartDesign::Draft', new Set(['Base', 'Angle', 'Reversed'])], + ['PartDesign::Thickness', new Set(['Base', 'Value', 'Join', 'Mode', 'Reversed'])], + ['PartDesign::Mirrored', new Set(['Originals', 'TransformMode'])], + ['PartDesign::MultiTransform', new Set(['Originals', 'TransformMode', 'Transformations'])], + ['PartDesign::LinearPattern', new Set(['Originals', 'TransformMode', 'Occurrences', 'Length', 'Offset', 'Direction', 'Mode', 'Reversed', 'Spacings', 'SpacingPattern', 'Direction2', 'Mode2', 'Length2', 'Offset2', 'Occurrences2', 'Reversed2', 'Spacings2', 'SpacingPattern2'])], + ['PartDesign::PolarPattern', new Set(['Originals', 'TransformMode', 'Occurrences', 'Angle', 'Axis', 'Mode', 'Offset', 'Spacings', 'SpacingPattern', 'Reversed'])], ['PartDesign::Hole', new Set(['Threaded', 'ModelThread', 'ThreadType', 'ThreadSize', 'ThreadClass', 'ThreadFit', 'Diameter', 'ThreadDiameter', 'ThreadDirection', 'HoleCutType', 'HoleCutCustomValues', 'HoleCutDiameter', 'HoleCutDepth', 'HoleCutCountersinkAngle', 'DepthType', 'Depth', 'DrillPoint', 'DrillPointAngle', 'DrillForDepth', 'Tapered', 'TaperedAngle', 'ThreadDepthType', 'ThreadDepth', 'UseCustomThreadClearance', 'CustomThreadClearance', 'BaseProfileType'])], ['Part::Fuse', new Set(['Base', 'Tool', 'Refine'])], ['Part::Cut', new Set(['Base', 'Tool', 'Refine'])], @@ -384,6 +404,9 @@ const sketchGeometryType = { circle: 'Part::GeomCircle', arc: 'Part::GeomArcOfCircle', ellipse: 'Part::GeomEllipse', + arcEllipse: 'Part::GeomArcOfEllipse', + arcHyperbola: 'Part::GeomArcOfHyperbola', + arcParabola: 'Part::GeomArcOfParabola', bspline: 'Part::GeomBSplineCurve', } as const @@ -393,6 +416,9 @@ const sketchGeometryPayloadXml = (geometry: SketchGeometry) => { if (geometry.type === 'circle') return `` if (geometry.type === 'arc') return `` if (geometry.type === 'ellipse') return `` + if (geometry.type === 'arcEllipse') return `` + if (geometry.type === 'arcHyperbola') return `` + if (geometry.type === 'arcParabola') return `` const weights = geometry.weights ?? geometry.controlPoints.map(() => 1) const knotCount = geometry.controlPoints.length + geometry.degree + 1 const knots = geometry.knots ?? Array.from({ length: knotCount }, (_, index) => { @@ -411,7 +437,7 @@ const sketchGeometryPayloadXml = (geometry: SketchGeometry) => { return `${poles}${knotXml}` } -type SketchInternalAlignmentType = 1 | 2 | 3 | 4 | 9 | 10 +type SketchInternalAlignmentType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 type SketchInternalGeometryHelper = { key: string @@ -533,8 +559,8 @@ const sketchInternalGeometryPlan = (sketch: SketchSnapshot): SketchInternalGeome firstPosition: 1, geometry: { id: `Internal${helpers.size}`, type: 'point', position: bsplinePointAtParameter(target, uniqueKnots[constraint.internalGeometryIndex]), construction: true }, }) - } else { - if (target.type !== 'ellipse') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires ellipse geometry '${constraint.geometryId}'.`) + } else if (constraint.alignmentType.startsWith('ellipse-')) { + if (target.type !== 'ellipse' && target.type !== 'arcEllipse') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires ellipse geometry '${constraint.geometryId}'.`) const focusIndex = constraint.alignmentType === 'ellipse-focus' ? constraint.internalGeometryIndex : 0 if ((constraint.alignmentType === 'ellipse-focus' && focusIndex !== 0 && focusIndex !== 1) || (constraint.alignmentType !== 'ellipse-focus' && constraint.internalGeometryIndex !== 0)) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' has an invalid ellipse internal index.`) const alignmentType = constraint.alignmentType === 'ellipse-major' ? 1 : constraint.alignmentType === 'ellipse-minor' ? 2 : focusIndex === 0 ? 3 : 4 @@ -548,6 +574,31 @@ const sketchInternalGeometryPlan = (sketch: SketchSnapshot): SketchInternalGeome ? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.minorRadius * minorDirection.x, y: target.center.y + target.minorRadius * minorDirection.y }, end: { x: target.center.x - target.minorRadius * minorDirection.x, y: target.center.y - target.minorRadius * minorDirection.y }, construction: true } : { id: `Internal${helpers.size}`, type: 'point', position: { x: target.center.x + (alignmentType === 3 ? 1 : -1) * focalDistance * majorDirection.x, y: target.center.y + (alignmentType === 3 ? 1 : -1) * focalDistance * majorDirection.y }, construction: true } helper = helpers.get(key) ?? addHelper({ key, targetGeometryId: constraint.geometryId, alignmentType, internalAlignmentIndex: -1, webInternalGeometryIndex: constraint.internalGeometryIndex, firstPosition: alignmentType <= 2 ? 0 : 1, geometry }) + } else if (constraint.alignmentType.startsWith('hyperbola-')) { + if (target.type !== 'arcHyperbola') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires hyperbola geometry '${constraint.geometryId}'.`) + if (constraint.internalGeometryIndex !== 0) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' has an invalid hyperbola internal index.`) + const alignmentType = constraint.alignmentType === 'hyperbola-major' ? 5 : constraint.alignmentType === 'hyperbola-minor' ? 6 : 7 + const key = `${constraint.geometryId}\0${constraint.alignmentType}\0${0}` + const majorDirection = { x: Math.cos(target.rotation), y: Math.sin(target.rotation) } + const minorDirection = { x: -majorDirection.y, y: majorDirection.x } + const focalDistance = Math.sqrt(target.majorRadius * target.majorRadius + target.minorRadius * target.minorRadius) + const geometry: SketchInternalGeometryHelper['geometry'] = alignmentType === 5 + ? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.majorRadius * majorDirection.x, y: target.center.y + target.majorRadius * majorDirection.y }, end: { x: target.center.x - target.majorRadius * majorDirection.x, y: target.center.y - target.majorRadius * majorDirection.y }, construction: true } + : alignmentType === 6 + ? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.minorRadius * minorDirection.x, y: target.center.y + target.minorRadius * minorDirection.y }, end: { x: target.center.x - target.minorRadius * minorDirection.x, y: target.center.y - target.minorRadius * minorDirection.y }, construction: true } + : { id: `Internal${helpers.size}`, type: 'point', position: { x: target.center.x + focalDistance * majorDirection.x, y: target.center.y + focalDistance * majorDirection.y }, construction: true } + helper = helpers.get(key) ?? addHelper({ key, targetGeometryId: constraint.geometryId, alignmentType, internalAlignmentIndex: -1, webInternalGeometryIndex: 0, firstPosition: alignmentType <= 6 ? 0 : 1, geometry }) + } else { + if (target.type !== 'arcParabola' || (constraint.alignmentType !== 'parabola-focus' && constraint.alignmentType !== 'parabola-focal-axis')) throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires parabola geometry '${constraint.geometryId}'.`) + if (constraint.internalGeometryIndex !== 0) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' has an invalid parabola internal index.`) + const alignmentType = constraint.alignmentType === 'parabola-focus' ? 8 : 11 + const key = `${constraint.geometryId}\0${constraint.alignmentType}\0${0}` + const direction = { x: Math.cos(target.rotation), y: Math.sin(target.rotation) } + const focus = { x: target.center.x + target.focal * direction.x, y: target.center.y + target.focal * direction.y } + const geometry: SketchInternalGeometryHelper['geometry'] = alignmentType === 8 + ? { id: `Internal${helpers.size}`, type: 'point', position: focus, construction: true } + : { id: `Internal${helpers.size}`, type: 'line', start: { ...target.center }, end: focus, construction: true } + helper = helpers.get(key) ?? addHelper({ key, targetGeometryId: constraint.geometryId, alignmentType, internalAlignmentIndex: -1, webInternalGeometryIndex: 0, firstPosition: alignmentType === 8 ? 1 : 0, geometry }) } if (helper.explicitAlignmentId) throw new Error(`FCStd native Sketch has duplicate InternalAlignment constraints for '${constraint.geometryId}' internal geometry ${constraint.internalGeometryIndex}.`) helper.explicitAlignmentId = constraint.id @@ -746,7 +797,7 @@ const sketchConstraintPoint = (geometryIndex: Map, geometryById: if (!geometry) throw new RangeError(`FCStd Sketch ${context} references unknown geometry '${reference.geometryId}'.`) const valid = geometry.type === 'line' ? reference.point === 'start' || reference.point === 'end' : geometry.type === 'point' ? reference.point === 'position' - : geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' ? reference.point === 'center' + : geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola' ? reference.point === 'center' : false if (!valid) throw new RangeError(`FCStd Sketch ${context} point '${reference.point}' is invalid for ${geometry.type} '${geometry.id}'.`) return sketchConstraintElement(geometryIndex, geometryById, reference.geometryId, sketchPointPosition[reference.point], context) @@ -1370,6 +1421,9 @@ const structuredPropertyValue = (element: string, elementValue: unknown, propert else if (type === 'Part::GeomCircle') value = { id: String(nativeId), type: 'circle', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, radius: finite(payload, 'Radius') } else if (type === 'Part::GeomArcOfCircle') value = { id: String(nativeId), type: 'arc', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, radius: finite(payload, 'Radius'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') } else if (type === 'Part::GeomEllipse') value = { id: String(nativeId), type: 'ellipse', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, majorRadius: finite(payload, 'MajorRadius'), minorRadius: finite(payload, 'MinorRadius'), rotation: finite(payload, 'AngleXU') } + else if (type === 'Part::GeomArcOfEllipse') value = { id: String(nativeId), type: 'arcEllipse', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, majorRadius: finite(payload, 'MajorRadius'), minorRadius: finite(payload, 'MinorRadius'), rotation: finite(payload, 'AngleXU'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') } + else if (type === 'Part::GeomArcOfHyperbola') value = { id: String(nativeId), type: 'arcHyperbola', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, majorRadius: finite(payload, 'MajorRadius'), minorRadius: finite(payload, 'MinorRadius'), rotation: finite(payload, 'AngleXU'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') } + else if (type === 'Part::GeomArcOfParabola') value = { id: String(nativeId), type: 'arcParabola', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, focal: finite(payload, 'Focal'), rotation: finite(payload, 'AngleXU'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') } else if (type === 'Part::GeomBSplineCurve') { const curve = payload && typeof payload === 'object' ? payload as Record : {} const poles = asArray(curve.Pole as Record | Record[] | undefined) @@ -1676,7 +1730,7 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS const candidate = nativeGeometryForNativeId(nativeId) return candidate && !candidate.freecadInternalType ? candidate : undefined } - type InternalHelperBinding = { geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'bspline-control-point' | 'bspline-knot' } + type InternalHelperBinding = { geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'hyperbola-major' | 'hyperbola-minor' | 'hyperbola-focus' | 'parabola-focus' | 'parabola-focal-axis' | 'bspline-control-point' | 'bspline-knot' } const internalHelperBindings = new Map() for (const record of constraints) { if (record.type !== 15) continue @@ -1688,11 +1742,20 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS if (!helper || helper.freecadInternalType !== record.internalAlignmentType || !target || record.positions[1] !== 0 || record.ids[2] !== -2000 || record.positions[2] !== 0) return undefined let binding: InternalHelperBinding | undefined if (record.internalAlignmentType === 1 || record.internalAlignmentType === 2) { - if (helper.type !== 'line' || target.type !== 'ellipse' || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined + if (helper.type !== 'line' || (target.type !== 'ellipse' && target.type !== 'arcEllipse') || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: record.internalAlignmentType === 1 ? 'ellipse-major' : 'ellipse-minor' } } else if (record.internalAlignmentType === 3 || record.internalAlignmentType === 4) { - if (helper.type !== 'point' || target.type !== 'ellipse' || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined + if (helper.type !== 'point' || (target.type !== 'ellipse' && target.type !== 'arcEllipse') || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentType === 3 ? 0 : 1, alignmentType: 'ellipse-focus' } + } else if (record.internalAlignmentType === 5 || record.internalAlignmentType === 6) { + if (helper.type !== 'line' || target.type !== 'arcHyperbola' || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined + binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: record.internalAlignmentType === 5 ? 'hyperbola-major' : 'hyperbola-minor' } + } else if (record.internalAlignmentType === 7) { + if (helper.type !== 'point' || target.type !== 'arcHyperbola' || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined + binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: 'hyperbola-focus' } + } else if (record.internalAlignmentType === 8) { + if (helper.type !== 'point' || target.type !== 'arcParabola' || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined + binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: 'parabola-focus' } } else if (record.internalAlignmentType === 9) { if (helper.type !== 'circle' || target.type !== 'bspline' || record.positions[0] !== 3 || record.internalAlignmentIndex < 0 || record.internalAlignmentIndex >= target.controlPoints.length) return undefined binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentIndex, alignmentType: 'bspline-control-point' } @@ -1701,6 +1764,9 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS const uniqueKnots = bsplineExpandedKnots(target).filter((knot, index, knots) => index === 0 || knot !== knots[index - 1]) if (record.internalAlignmentIndex < 0 || record.internalAlignmentIndex >= uniqueKnots.length) return undefined binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentIndex, alignmentType: 'bspline-knot' } + } else if (record.internalAlignmentType === 11) { + if (helper.type !== 'line' || target.type !== 'arcParabola' || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined + binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: 'parabola-focal-axis' } } else return undefined const existing = internalHelperBindings.get(helperNativeId) if (existing && JSON.stringify(existing) !== JSON.stringify(binding)) throw new Error(`FCStd Sketch ${objectId} internal helper ${helperNativeId} has conflicting geometry bindings.`) @@ -1715,7 +1781,7 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS const position = record.positions[slot] if (candidate.type === 'line') return position === 1 ? { geometryId: candidate.id, point: 'start' } : position === 2 ? { geometryId: candidate.id, point: 'end' } : undefined if (candidate.type === 'point') return position === 1 ? { geometryId: candidate.id, point: 'position' } : undefined - if (candidate.type === 'circle' || candidate.type === 'arc' || candidate.type === 'ellipse') return position === 3 ? { geometryId: candidate.id, point: 'center' } : undefined + if (candidate.type === 'circle' || candidate.type === 'arc' || candidate.type === 'ellipse' || candidate.type === 'arcEllipse' || candidate.type === 'arcHyperbola' || candidate.type === 'arcParabola') return position === 3 ? { geometryId: candidate.id, point: 'center' } : undefined return undefined } const geometryRef = (record: typeof constraints[number], slot: number) => geometryForNativeId(record.ids[slot])?.id diff --git a/src/facade/index.ts b/src/facade/index.ts index d24d4d4..d3f0d8f 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -77,14 +77,18 @@ export { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from ' export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStage, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider' export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOcctHistoryUnavailableError, UnavailableNativeOcctHistoryProvider } from './nativeHistoryProtocol' export { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } from './nativeHistoryProtocol' -export { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, nativeNamingAbiCapabilities } from './nativeHistoryProtocol' +export { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, nativeNamingAbiCapabilities, nativeNamingCapabilitiesForModule } from './nativeHistoryProtocol' 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 type { NativeMappedNameRef, NativeMappedNameRelation, NativeNamingEvidenceIssue, NativeNamingEvidenceReport, NativeNamingEvidenceStatus, NativeStageNamingEvidence } from './nativeNamingEvidence' export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient' export type { NativeOcctHistoryWorkerOptions } from './nativeHistoryWorkerClient' -export { applySketchAutoConstraints, BasicSketchSolverAdapter, carbonCopySketchGeometry, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, projectSketchGeometry, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, validateSketchSnapshot } from './sketcher' +export { applySketchAutoConstraints, BasicSketchSolverAdapter, carbonCopySketchGeometry, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, FREECAD_SKETCHER_CONSTRAINT_TYPES, FREECAD_SKETCHER_GEOMETRY_TYPES, FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES, projectSketchGeometry, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, validateSketchSnapshot } from './sketcher' export type { BsplineGeometryPatch, SketchAutoConstraintSuggestion, SketchConstraint, SketchDiagnostic, SketchEditorEvent, SketchEditorReplayResult, SketchExternalGeometry, SketchExternalMode, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher' +export { assertPartDesignParameterSet, parameterValuesForObject, PARTDESIGN_PARAMETER_SPACE, partDesignParameterSpaceCoverage, validatePartDesignParameterSet } from './partDesignParameters' +export type { PartDesignParameterFamily, PartDesignParameterIssue, PartDesignParameterPartition, PartDesignParameterValidation } from './partDesignParameters' export { assertSketchSolverRequest, assertSketchSolverResponse, BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay } from './sketchSolverProtocol' export type { SketchSolverCapabilities, SketchSolverCompatibility, SketchSolverExecution, SketchSolverProvider, SketchSolverReplayCase, SketchSolverReplayResult, SketchSolverRequest, SketchSolverResponse } from './sketchSolverProtocol' export { PLANEGCS_WASM_CAPABILITIES, PlanegcsSubsetError, solvePlanegcsSubset } from './planegcsAdapter' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 0f5cd04..3422db0 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -46,6 +46,7 @@ import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replac import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences' import { createCamJob } from './cam' import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates' +import { assertPartDesignParameterSet, parameterValuesForObject, validatePartDesignParameterSet } from './partDesignParameters' const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('ellipsoid') ? 'Part::Ellipsoid' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('torus') ? 'Part::Torus' : item.id.startsWith('helix') ? 'Part::Helix' : item.id.startsWith('prism') ? 'Part::Prism' : item.id.startsWith('wedge') ? 'Part::Wedge' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('groove') ? 'PartDesign::Groove' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('multi-transform') ? 'PartDesign::MultiTransform' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' @@ -145,32 +146,54 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { { name: 'SideType', label: 'Side definition', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'One side', options: ['One side', 'Two sides', 'Symmetric'], recompute: true }, { name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all', 'Up to face', 'TwoLengths'], recompute: true }, { name: 'Type2', label: 'Type 2', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all', 'Up to face'], recompute: true }, + { name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, + { name: 'UpToFace2', label: 'Reverse up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, { name: 'Length2', label: 'Reverse length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true }, { name: 'TaperAngle', label: 'Taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true }, { name: 'TaperAngle2', label: 'Reverse taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, { name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + { name: 'ReferenceAxis', label: 'Reference axis', group: 'Direction', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, + { name: 'AlongSketchNormal', label: 'Along sketch normal', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true }, + { name: 'UseCustomVector', label: 'Use custom vector', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + { name: 'Direction', label: 'Direction', group: 'Direction', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 1 }, recompute: true }, + { name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true }, + { name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true }, ] if (item.id.startsWith('pocket')) return [ { name: 'SideType', label: 'Side definition', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'One side', options: ['One side', 'Two sides', 'Symmetric'], recompute: true }, { name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Through all', options: ['Dimension', 'Through all', 'Up to face', 'TwoLengths'], recompute: true }, { name: 'Type2', label: 'Type 2', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all', 'Up to face'], recompute: true }, { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 18, unit: 'mm', recompute: true }, + { name: 'Length2', label: 'Reverse length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, { name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true }, { name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, + { name: 'UpToFace2', label: 'Reverse up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, { name: 'TaperAngle', label: 'Taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true }, { name: 'TaperAngle2', label: 'Reverse taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, { name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + { name: 'ReferenceAxis', label: 'Reference axis', group: 'Direction', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, + { name: 'AlongSketchNormal', label: 'Along sketch normal', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true }, + { name: 'UseCustomVector', label: 'Use custom vector', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + { name: 'Direction', label: 'Direction', group: 'Direction', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 1 }, recompute: true }, + { name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true }, + { name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true }, ] if (item.id.startsWith('revolution')) return [ { name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true }, { name: 'Angle2', label: 'Reverse angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true }, { name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Angle', options: ['Angle', 'To last', 'To first', 'Up to face', 'Two angles'], recompute: true }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, + { name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, + { name: 'ReferenceAxis', label: 'Reference axis', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, + { name: 'Axis', label: 'Axis', group: 'Axis', scope: 'data', type: 'App::PropertyEnumeration', value: 'Vertical sketch axis', options: ['Horizontal sketch axis', 'Vertical sketch axis', 'Custom'], recompute: true }, + { name: 'AxisLink', label: 'Axis link', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, { name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + { name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true }, + { name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true }, ] if (item.id.startsWith('groove')) return [ { name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true }, @@ -178,8 +201,14 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { { name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Angle', options: ['Angle', 'To last', 'To first', 'Up to face', 'Two angles'], recompute: true }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, { name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true }, + { name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, + { name: 'ReferenceAxis', label: 'Reference axis', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, + { name: 'Axis', label: 'Axis', group: 'Axis', scope: 'data', type: 'App::PropertyEnumeration', value: 'Vertical sketch axis', options: ['Horizontal sketch axis', 'Vertical sketch axis', 'Custom'], recompute: true }, + { name: 'AxisLink', label: 'Axis link', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true }, { name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + { name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true }, + { name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true }, ] if (item.id.startsWith('fillet')) return [ { name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true }, @@ -885,6 +914,8 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW ...commonProperties(item).map((property) => property.name === 'TypeId' ? { ...property, value: typeId } : property), { name: 'Base', label: 'Base edge', group: 'Chamfer', scope: 'data', type: 'App::PropertyLinkSub', value: selectedEdgeRef, recompute: true }, { name: 'Size', label: 'Size', group: 'Chamfer', scope: 'data', type: 'App::PropertyLength', value: 1, unit: 'mm', recompute: true }, + { name: 'Size2', label: 'Second size', group: 'Chamfer', scope: 'data', type: 'App::PropertyLength', value: 1, unit: 'mm', recompute: true }, + { name: 'Angle', label: 'Angle', group: 'Chamfer', scope: 'data', type: 'App::PropertyAngle', value: 45, unit: 'deg', recompute: true }, { name: 'ChamferType', label: 'Chamfer type', group: 'Chamfer', scope: 'data', type: 'App::PropertyEnumeration', value: 'Equal distance', options: ['Equal distance', 'Two distances', 'Distance and Angle'], recompute: true }, { name: 'FlipDirection', label: 'Flip direction', group: 'Chamfer', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, { name: 'UseAllEdges', label: 'Use all edges', group: 'Chamfer', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, @@ -913,6 +944,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW { name: 'Value', label: 'Value', group: 'Thickness', scope: 'data', type: 'App::PropertyLength', value: 1, unit: 'mm', recompute: true }, { name: 'RemoveFaces', label: 'Remove faces', group: 'Thickness', scope: 'data', type: 'App::PropertyLinkSub', value: selectedFaceRef, recompute: true }, { name: 'Join', label: 'Join type', group: 'Thickness', scope: 'data', type: 'App::PropertyEnumeration', value: 'Arc', options: ['Arc', 'Intersection'], recompute: true }, + { name: 'Mode', label: 'Mode', group: 'Thickness', scope: 'data', type: 'App::PropertyEnumeration', value: 'Skin', options: ['Skin', 'Pipe', 'Recto verso'], recompute: true }, { name: 'Reversed', label: 'Reversed', group: 'Thickness', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, ...viewProperties(), ] @@ -1002,6 +1034,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW if (commandId !== 'sweep-part' && !bodyTip) throw new RangeError('Part Design Pipe requires a valid Body Tip base feature.') } for (const property of objectSnapshot.properties.filter((candidate) => candidate.recompute && !candidate.readOnly)) validatePropertyValue(document, property, property.value) + assertPartDesignParameterSet(objectSnapshot) if (commandId === 'linear-pattern' && Number(objectSnapshot.properties.find((property) => property.name === 'Length')?.value) <= 0) throw new RangeError('Linear pattern length must be greater than zero.') if (commandId === 'polar-pattern' && Number(objectSnapshot.properties.find((property) => property.name === 'Angle')?.value) <= 0) throw new RangeError('Polar pattern angle must be greater than zero.') if (commandId === 'hole') { @@ -1033,6 +1066,8 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW const document = cloneDocumentSnapshot(state.document) const object = document.objects[objectIndex] object.properties[propertyIndex] = { ...object.properties[propertyIndex], value: clonePropertyValue(value), expression: undefined, expressionError: undefined } + const parameterReport = validatePartDesignParameterSet(object.typeId, parameterValuesForObject(object), { requireComplete: false }) + if (!parameterReport.valid) throw new RangeError(`${object.typeId}.${parameterReport.issues[0].propertyName}: ${parameterReport.issues[0].message}`) const treeItem = document.tree.find((item) => item.id === objectId) if (propertyName === 'Label' && treeItem) treeItem.label = String(value) document.dependencies = collectDependencyEdges(document) diff --git a/src/facade/nativeHistoryProtocol.ts b/src/facade/nativeHistoryProtocol.ts index 7c9ec49..43a0521 100644 --- a/src/facade/nativeHistoryProtocol.ts +++ b/src/facade/nativeHistoryProtocol.ts @@ -1,16 +1,24 @@ import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctHistoryStepProvider, NativeOcctMultiTransformStep } from './nativeHistoryProvider' +import { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi' export const NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1 as const export type NativeNamingAbiCapabilities = { mappedNameRef: 'available' | 'optional' | 'unavailable' stringHasher: 'available' | 'opaque-preserved' | 'unavailable' + elementMap2: 'available' | 'opaque-preserved' | 'unavailable' tokenGeneration: 'native-only' | 'forbidden' + abiVersion?: number + freecadVersion?: string + sourceCommit?: string + operations?: NativeOcctHistoryOperation[] + reason?: string } export const NATIVE_OCCT_NAMING_ABI_UNAVAILABLE: NativeNamingAbiCapabilities = Object.freeze({ mappedNameRef: 'unavailable', stringHasher: 'unavailable', + elementMap2: 'unavailable', tokenGeneration: 'forbidden', }) @@ -29,8 +37,24 @@ export type NativeOcctHistoryCapabilities = { export const nativeNamingAbiCapabilities = (capabilities: NativeOcctHistoryCapabilities): NativeNamingAbiCapabilities => ({ ...NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, ...(capabilities.naming ?? {}), + ...(capabilities.naming?.operations ? { operations: [...capabilities.naming.operations] } : {}), }) +export const nativeNamingCapabilitiesForModule = (module: NativeOcctHistoryStepProvider): NativeNamingAbiCapabilities => { + const probe = probeFreeCadPrivateNamingAbi(module) + if (probe.availability !== 'available' || !probe.descriptor) return { ...NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, ...(probe.reason ? { reason: probe.reason } : {}), ...(probe.abiVersion === null ? {} : { abiVersion: probe.abiVersion }) } + return { + mappedNameRef: 'available', + stringHasher: 'available', + elementMap2: 'available', + tokenGeneration: 'native-only', + abiVersion: probe.abiVersion ?? undefined, + freecadVersion: probe.descriptor.freecadVersion, + sourceCommit: probe.descriptor.sourceCommit, + operations: [...probe.descriptor.operations], + } +} + export type NativeOcctHistoryRequest = { protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION requestId: string @@ -241,6 +265,7 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text', + naming: nativeNamingCapabilitiesForModule(this.module), } } @@ -323,7 +348,27 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide return this.module.booleanHistoryFromStep(request.objectStep, request.toolStep!, request.operation) }) if (signal.aborted) throw abortError() - return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history: attachStageMetadata(request, history) }) + const stagedHistory = attachStageMetadata(request, history) + const orderedStages = [...(request.stages ?? [])].sort((left, right) => left.ordinal - right.ordinal) + const finalStage = orderedStages.at(-1) + const stageId = finalStage?.resultStageId ?? finalStage?.stageId ?? `${request.operationId}:stage:0` + const namingEvidence = captureFreeCadPrivateNamingEvidence(this.module, createFreeCadPrivateNamingAbiRequest({ + requestId: request.requestId, + documentId: request.documentId, + documentVersion: request.documentVersion, + 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 } : {}) })), + stages: orderedStages.map((stage) => ({ ...stage, inputIds: [...stage.inputIds] })), + ...(stagedHistory.resultStep ? { resultStep: stagedHistory.resultStep } : {}), + ...(stagedHistory.resultBrep ? { resultBrep: stagedHistory.resultBrep } : {}), + ...(request.resultStepByStage ? { resultStepByStage: { ...request.resultStepByStage } } : {}), + history: stagedHistory, + })) + const enrichedHistory = namingEvidence ? { ...stagedHistory, namingEvidence } : stagedHistory + return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history: enrichedHistory }) } } diff --git a/src/facade/nativeHistoryProvider.ts b/src/facade/nativeHistoryProvider.ts index 7db161b..492326e 100644 --- a/src/facade/nativeHistoryProvider.ts +++ b/src/facade/nativeHistoryProvider.ts @@ -1,5 +1,6 @@ import type { NativeMultiTransformHistoryStep, NativeTopologyHistoryInput, NativeTopologyHistoryRecord, ShapeHandle, SubshapeRef } from './types' import type { NativeStageNamingEvidence } from './nativeNamingEvidence' +import type { NativeFreeCadNamingAbiModule } from './nativeNamingAbi' export type NativeOcctMultiTransformStep = NativeMultiTransformHistoryStep @@ -54,7 +55,7 @@ export type NativeOcctHistoryResponse = { export type NativeOcctHistoryOperation = 'fuse' | 'cut' | 'common' | 'rotate' | 'pad' | 'pocket' | 'loft' | 'pipe' | 'revolution' | 'groove' | 'fillet' | 'chamfer' | 'hole' | 'draft' | 'thickness' | 'linear-pattern' | 'polar-pattern' | 'mirrored' | 'multi-transform' -export type NativeOcctHistoryStepProvider = { +export type NativeOcctHistoryStepProvider = NativeFreeCadNamingAbiModule & { occtVersion(): string booleanHistoryFromStep(objectStep: string, toolStep: string, operation: NativeOcctHistoryOperation): NativeOcctHistoryResponse rotateHistoryFromStep?(shapeStep: string, axisOriginX: number, axisOriginY: number, axisOriginZ: number, axisDirectionX: number, axisDirectionY: number, axisDirectionZ: number, angleDegrees: number): NativeOcctHistoryResponse diff --git a/src/facade/nativeHistoryWorkerEntry.ts b/src/facade/nativeHistoryWorkerEntry.ts index a7668ee..85f1400 100644 --- a/src/facade/nativeHistoryWorkerEntry.ts +++ b/src/facade/nativeHistoryWorkerEntry.ts @@ -1,7 +1,8 @@ /// import type { NativeOcctHistoryStepProvider } from './nativeHistoryProvider' -import { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, type NativeOcctHistoryCapabilities, type NativeOcctHistoryRequest, type NativeOcctHistoryProtocolResponse } from './nativeHistoryProtocol' +import { nativeNamingCapabilitiesForModule, type NativeOcctHistoryCapabilities, type NativeOcctHistoryRequest, type NativeOcctHistoryProtocolResponse } from './nativeHistoryProtocol' +import { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest } from './nativeNamingAbi' type WorkerRequest = { type: 'initialize'; moduleUrl: string } | { type: 'capture'; request: NativeOcctHistoryRequest } | { type: 'cancel'; requestId: string } | { type: 'dispose' } type WorkerResponse = { type: 'ready'; capabilities: NativeOcctHistoryCapabilities } | { type: 'response'; response: NativeOcctHistoryProtocolResponse } | { type: 'error'; requestId?: string; error: string } @@ -11,6 +12,16 @@ let provider: NativeOcctHistoryStepProvider | null = null const cancelled = new Set() const send = (message: WorkerResponse) => scope.postMessage(message) +const operations: NativeOcctHistoryCapabilities['operations'] = ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'] +const capabilitiesFor = (module: NativeOcctHistoryStepProvider, occtVersion = module.occtVersion()): NativeOcctHistoryCapabilities => ({ + providerId: 'occt-native.history-step', + providerVersion: '8.0.0-embind', + occtVersion, + availability: 'available', + operations: [...operations], + transport: 'step-text', + naming: nativeNamingCapabilitiesForModule(module), +}) const captureMultiTransform = (module: NativeOcctHistoryStepProvider, request: NativeOcctHistoryRequest) => { if (request.transforms && request.transforms.length >= 2) return module.multiTransformHistoryFromStep?.(request.objectStep, request.transforms) @@ -25,15 +36,7 @@ const initialize = async (moduleUrl: string) => { provider = await imported.default() send({ type: 'ready', - capabilities: { - providerId: 'occt-native.history-step', - providerVersion: '8.0.0-embind', - occtVersion: provider.occtVersion(), - availability: 'available', - operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], - transport: 'step-text', - naming: NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, - }, + capabilities: capabilitiesFor(provider), }) } @@ -81,7 +84,25 @@ scope.onmessage = ({ data }: MessageEvent) => { : provider.booleanHistoryFromStep(request.objectStep, request.toolStep || '', request.operation) if (!history) throw new Error('Native OCCT history provider does not expose the requested feature operation.') if (cancelled.delete(data.request.requestId)) return - send({ type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: history.occtVersion, availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text', naming: NATIVE_OCCT_NAMING_ABI_UNAVAILABLE }, history } }) + const orderedStages = [...(request.stages ?? [])].sort((left, right) => left.ordinal - right.ordinal) + const finalStage = orderedStages.at(-1) + const stageId = finalStage?.resultStageId ?? finalStage?.stageId ?? `${request.operationId}:stage:0` + const namingEvidence = captureFreeCadPrivateNamingEvidence(provider, createFreeCadPrivateNamingAbiRequest({ + requestId: request.requestId, + documentId: request.documentId, + documentVersion: request.documentVersion, + 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 } : {}) })), + stages: orderedStages.map((stage) => ({ ...stage, inputIds: [...stage.inputIds] })), + ...(history.resultStep ? { resultStep: history.resultStep } : {}), + ...(history.resultBrep ? { resultBrep: history.resultBrep } : {}), + ...(request.resultStepByStage ? { resultStepByStage: { ...request.resultStepByStage } } : {}), + history, + })) + send({ type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: capabilitiesFor(provider, history.occtVersion), history: namingEvidence ? { ...history, namingEvidence } : history } }) } catch (error) { send({ type: 'error', requestId: data.type === 'capture' ? data.request.requestId : undefined, error: error instanceof Error ? error.message : String(error) }) } diff --git a/src/facade/nativeNamingAbi.ts b/src/facade/nativeNamingAbi.ts new file mode 100644 index 0000000..15c4461 --- /dev/null +++ b/src/facade/nativeNamingAbi.ts @@ -0,0 +1,136 @@ +import { migrateElementMap2Schema, validateElementMap2 } from './elementMap2' +import { assertNativeNamingEvidence, type NativeStageNamingEvidence } from './nativeNamingEvidence' +import { migrateStringHasherSchema, validateElementMap2StringHasherEvidence, validateStringHasherTable } from './stringHasher' +import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse } from './nativeHistoryProvider' + +export const FREECAD_PRIVATE_NAMING_ABI_VERSION = 1 as const +export const FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES = 16 * 1024 * 1024 +export const FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES = 32 * 1024 * 1024 + +export type FreeCadPrivateNamingAbiDescriptor = { + schemaVersion: 1 + freecadVersion: string + sourceCommit: string + mappedNameRef: true + stringHasher: true + elementMap2: true + operations: NativeOcctHistoryOperation[] + maxRequestBytes?: number + maxResponseBytes?: number +} + +export type FreeCadPrivateNamingAbiProbe = { + availability: 'available' | 'unavailable' + abiVersion: number | null + descriptor?: FreeCadPrivateNamingAbiDescriptor + reason?: string +} + +/** Embind surface implemented only by a FreeCAD-linked native module. */ +export type NativeFreeCadNamingAbiModule = { + freecadNamingAbiVersion?(): number + freecadNamingCapabilitiesJson?(): string + freecadNamingEvidenceJson?(requestJson: string): string +} + +export type FreeCadPrivateNamingAbiRequest = { + schemaVersion: 1 + requestId: string + documentId: string + documentVersion: number + operationId: string + operation: NativeOcctHistoryOperation + stageId: string + resultObjectId: string + inputs: Array<{ inputId: string; role?: string; stageId?: string; step: string }> + stages: Array<{ stageId: string; operation?: NativeOcctHistoryOperation; inputIds: string[]; resultStageId?: string; ordinal: number }> + resultStep?: string + resultBrep?: string + resultStepByStage?: Record + history: Pick +} + +const encoder = new TextEncoder() +const lockedCommit = '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' +const supportedOperations = new Set(['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform']) + +const boundedLimit = (value: unknown, maximum: number) => Number.isSafeInteger(value) && (value as number) > 0 ? Math.min(value as number, maximum) : maximum + +export const probeFreeCadPrivateNamingAbi = (module: NativeFreeCadNamingAbiModule): FreeCadPrivateNamingAbiProbe => { + if (typeof module.freecadNamingAbiVersion !== 'function' || typeof module.freecadNamingCapabilitiesJson !== 'function' || typeof module.freecadNamingEvidenceJson !== 'function') return { + availability: 'unavailable', + abiVersion: null, + reason: 'Native module does not export the versioned FreeCAD naming callbacks.', + } + let abiVersion: number + try { abiVersion = module.freecadNamingAbiVersion() } catch (error) { + return { availability: 'unavailable', abiVersion: null, reason: `FreeCAD naming ABI version probe failed: ${error instanceof Error ? error.message : String(error)}` } + } + if (abiVersion !== FREECAD_PRIVATE_NAMING_ABI_VERSION) return { availability: 'unavailable', abiVersion, reason: `Unsupported FreeCAD naming ABI version ${abiVersion}.` } + try { + const raw = module.freecadNamingCapabilitiesJson() + if (encoder.encode(raw).byteLength > 64 * 1024) throw new RangeError('FreeCAD naming capability payload exceeds 64 KiB.') + const descriptor = JSON.parse(raw) as FreeCadPrivateNamingAbiDescriptor + if (descriptor.schemaVersion !== 1 || descriptor.freecadVersion !== '1.1.1' || descriptor.sourceCommit !== lockedCommit || descriptor.mappedNameRef !== true || descriptor.stringHasher !== true || descriptor.elementMap2 !== true) throw new TypeError('FreeCAD naming capabilities are not locked to the required 1.1.1 private ABI.') + if (!Array.isArray(descriptor.operations) || descriptor.operations.length === 0 || new Set(descriptor.operations).size !== descriptor.operations.length || descriptor.operations.some((operation) => !supportedOperations.has(operation))) throw new TypeError('FreeCAD naming capabilities require a unique non-empty supported operation list.') + return { + availability: 'available', + abiVersion, + descriptor: { + ...descriptor, + operations: [...descriptor.operations], + maxRequestBytes: boundedLimit(descriptor.maxRequestBytes, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES), + maxResponseBytes: boundedLimit(descriptor.maxResponseBytes, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES), + }, + } + } catch (error) { + return { availability: 'unavailable', abiVersion, reason: `FreeCAD naming capability probe failed: ${error instanceof Error ? error.message : String(error)}` } + } +} + +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.') + 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 } + }), + stages: input.stages.map((entry) => ({ ...entry, inputIds: [...entry.inputIds] })), + ...(input.resultStepByStage ? { resultStepByStage: { ...input.resultStepByStage } } : {}), + history: { ...input.history, records: input.history.records.map((record) => ({ ...record, resultIndexes: record.resultIndexes ? [...record.resultIndexes] : undefined })) }, + } +} + +export const captureFreeCadPrivateNamingEvidence = ( + module: NativeFreeCadNamingAbiModule, + request: FreeCadPrivateNamingAbiRequest, + probe = probeFreeCadPrivateNamingAbi(module), +): NativeStageNamingEvidence | undefined => { + if (probe.availability !== 'available' || !probe.descriptor || typeof module.freecadNamingEvidenceJson !== 'function') return undefined + if (!probe.descriptor.operations.includes(request.operation)) return undefined + const requestJson = JSON.stringify(request) + const requestBytes = encoder.encode(requestJson).byteLength + const requestLimit = boundedLimit(probe.descriptor.maxRequestBytes, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES) + if (requestBytes > requestLimit) throw new RangeError(`FreeCAD naming ABI request exceeds ${requestLimit} bytes.`) + const responseJson = module.freecadNamingEvidenceJson(requestJson) + if (typeof responseJson !== 'string') throw new TypeError('FreeCAD naming ABI must return a JSON string.') + const responseBytes = encoder.encode(responseJson).byteLength + const responseLimit = boundedLimit(probe.descriptor.maxResponseBytes, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES) + if (responseBytes > responseLimit) throw new RangeError(`FreeCAD naming ABI response exceeds ${responseLimit} bytes.`) + const evidence = assertNativeNamingEvidence(JSON.parse(responseJson) as NativeStageNamingEvidence) + if (evidence.stageId !== request.stageId || evidence.resultObjectId !== request.resultObjectId) throw new RangeError('FreeCAD naming ABI response does not match its stage and result object context.') + if (evidence.status !== 'native-evidence' && evidence.status !== 'ambiguous') throw new TypeError(`FreeCAD naming ABI cannot return non-native status '${evidence.status}'.`) + if (!evidence.stringHasher || !evidence.elementMap2) throw new TypeError('FreeCAD naming ABI response requires both StringHasher and ElementMap2 evidence.') + const stringHasher = migrateStringHasherSchema(evidence.stringHasher) + const stringHasherReport = validateStringHasherTable(stringHasher) + if (!stringHasherReport.valid) throw new Error(`FreeCAD naming ABI StringHasher is invalid: ${stringHasherReport.issues[0].path}: ${stringHasherReport.issues[0].message}`) + const elementMap2 = migrateElementMap2Schema(evidence.elementMap2) + const elementMap2Report = validateElementMap2(elementMap2) + if (!elementMap2Report.valid) throw new Error(`FreeCAD naming ABI ElementMap2 is invalid: ${elementMap2Report.issues[0].path}: ${elementMap2Report.issues[0].message}`) + const closureIssues = validateElementMap2StringHasherEvidence(elementMap2, stringHasher) + if (closureIssues.length > 0) throw new Error(`FreeCAD naming ABI StringHasher closure is invalid: ${closureIssues[0].path}: ${closureIssues[0].message}`) + return { ...evidence, stringHasher, elementMap2 } +} diff --git a/src/facade/partDesignParameters.ts b/src/facade/partDesignParameters.ts new file mode 100644 index 0000000..61a1061 --- /dev/null +++ b/src/facade/partDesignParameters.ts @@ -0,0 +1,288 @@ +import type { DocumentObjectSnapshot, PropertyValue, VectorValue } from './types' + +export type PartDesignParameterPartition = { + id: string + description: string + values: Record +} + +export type PartDesignParameterFamily = { + typeId: string + properties: readonly string[] + partitions: readonly PartDesignParameterPartition[] +} + +export type PartDesignParameterIssue = { + code: 'MISSING_PROPERTY' | 'INVALID_COMBINATION' | 'INVALID_RANGE' | 'INVALID_REFERENCE' | 'INVALID_VECTOR' + propertyName: string + message: string +} + +export type PartDesignParameterValidation = { + typeId: string + covered: boolean + valid: boolean + issues: PartDesignParameterIssue[] +} + +const partition = (id: string, description: string, values: Record): PartDesignParameterPartition => ({ id, description, values }) +const family = (typeId: string, properties: readonly string[], partitions: readonly PartDesignParameterPartition[]): PartDesignParameterFamily => ({ typeId, properties, partitions }) + +const attachmentProperties = ['Support', 'MapMode', 'AttachmentOffset', 'DatumType'] as const +const linearProperties = ['Profile', 'Length', 'Length2', 'Type', 'Type2', 'SideType', 'UpToFace', 'UpToFace2', 'TaperAngle', 'TaperAngle2', 'Reversed', 'Midplane', 'ReferenceAxis', 'AlongSketchNormal', 'UseCustomVector', 'Direction', 'Offset', 'Offset2'] as const +const angularProperties = ['Profile', 'Angle', 'Angle2', 'Type', 'UpToFace', 'ReferenceAxis', 'Axis', 'AxisLink', 'Midplane', 'Reversed', 'Offset', 'Offset2'] as const +const loftProperties = ['Profile', 'Sections', 'Base', 'Ruled', 'Closed', 'Solid'] as const +const pipeProperties = ['Profile', 'Spine', 'Base', 'Transition', 'Mode', 'Transformation', 'Solid'] as const + +export const PARTDESIGN_PARAMETER_SPACE: readonly PartDesignParameterFamily[] = Object.freeze([ + family('PartDesign::Plane', attachmentProperties, [ + partition('detached', 'No support and Deactivated map mode.', { Support: null, MapMode: 'Deactivated' }), + partition('flat-face', 'Stable face support.', { MapMode: 'FlatFace' }), + partition('three-points', 'Three-point plane attachment.', { MapMode: 'ThreePointsPlane' }), + ]), + family('PartDesign::Line', attachmentProperties, [ + partition('detached', 'No support and Deactivated map mode.', { Support: null, MapMode: 'Deactivated' }), + partition('normal-edge', 'Stable edge support.', { MapMode: 'NormalToEdge' }), + partition('two-points', 'Two-point line attachment.', { MapMode: 'TwoPointLine' }), + ]), + family('PartDesign::Point', attachmentProperties, [ + partition('detached', 'No support and Deactivated map mode.', { Support: null, MapMode: 'Deactivated' }), + partition('vertex', 'Stable vertex support.', { MapMode: 'Vertex' }), + partition('center-mass', 'Center-of-mass attachment.', { MapMode: 'CenterOfMass' }), + ]), + family('PartDesign::ShapeBinder', ['Support', 'BindMode', 'TraceSupport', 'ClaimChildren'], [ + partition('synchronized', 'Live synchronized support.', { BindMode: 'Synchronized', TraceSupport: true }), + partition('frozen', 'Frozen support snapshot.', { BindMode: 'Frozen', TraceSupport: false }), + partition('claim-children', 'Binder owns source children.', { ClaimChildren: true }), + ]), + family('PartDesign::Pad', linearProperties, [ + partition('dimension', 'One finite side.', { Type: 'Dimension', SideType: 'One side', Length: 10, Reversed: false }), + partition('two-lengths', 'Independent forward and reverse sides.', { Type: 'TwoLengths', SideType: 'Two sides', Length: 10, Length2: 5 }), + partition('midplane', 'Symmetric finite pad.', { Type: 'Dimension', SideType: 'Symmetric', Length: 10, Midplane: true }), + partition('up-to-face', 'Stable terminating face.', { Type: 'Up to face' }), + partition('custom-vector', 'Explicit extrusion vector.', { UseCustomVector: true, Direction: { x: 0, y: 0, z: 1 } }), + ]), + family('PartDesign::Pocket', ['Base', ...linearProperties], [ + partition('dimension', 'One finite side.', { Type: 'Dimension', SideType: 'One side', Length: 10, Reversed: false }), + partition('through-all', 'Through-all removal.', { Type: 'Through all', SideType: 'One side' }), + partition('two-lengths', 'Independent forward and reverse sides.', { Type: 'TwoLengths', SideType: 'Two sides', Length: 10, Length2: 5 }), + partition('midplane', 'Symmetric finite pocket.', { Type: 'Dimension', SideType: 'Symmetric', Length: 10, Midplane: true }), + partition('up-to-face', 'Stable terminating face.', { Type: 'Up to face' }), + ]), + family('PartDesign::Revolution', angularProperties, [ + partition('angle', 'One angular side.', { Type: 'Angle', Angle: 180, Reversed: false }), + partition('two-angles', 'Independent positive and reverse angles.', { Type: 'Two angles', Angle: 120, Angle2: 60 }), + partition('midplane', 'Symmetric angular feature.', { Type: 'Angle', Angle: 180, Midplane: true }), + partition('up-to-face', 'Stable angular terminating face.', { Type: 'Up to face' }), + ]), + family('PartDesign::Groove', ['Base', ...angularProperties], [ + partition('angle', 'One angular side.', { Type: 'Angle', Angle: 180, Reversed: false }), + partition('two-angles', 'Independent positive and reverse angles.', { Type: 'Two angles', Angle: 120, Angle2: 60 }), + partition('midplane', 'Symmetric angular feature.', { Type: 'Angle', Angle: 180, Midplane: true }), + partition('up-to-face', 'Stable angular terminating face.', { Type: 'Up to face' }), + ]), + family('PartDesign::AdditiveLoft', loftProperties, [ + partition('smooth', 'Smooth additive loft.', { Ruled: false, Closed: false, Solid: true }), + partition('ruled', 'Ruled additive loft.', { Ruled: true, Closed: false, Solid: true }), + partition('closed', 'Closed additive loft.', { Ruled: false, Closed: true, Solid: true }), + ]), + family('PartDesign::SubtractiveLoft', loftProperties, [ + partition('smooth', 'Smooth subtractive loft.', { Ruled: false, Closed: false, Solid: true }), + partition('ruled', 'Ruled subtractive loft.', { Ruled: true, Closed: false, Solid: true }), + partition('closed', 'Closed subtractive loft.', { Ruled: false, Closed: true, Solid: true }), + ]), + family('PartDesign::AdditivePipe', pipeProperties, [ + partition('standard', 'Standard transformed pipe.', { Mode: 'Standard', Transition: 'Transformed', Transformation: 'Constant' }), + partition('frenet', 'Frenet pipe.', { Mode: 'Frenet', Transition: 'Round corner', Transformation: 'Constant' }), + partition('multisection', 'Multi-section pipe.', { Mode: 'Standard', Transition: 'Right corner', Transformation: 'Multisection' }), + ]), + family('PartDesign::SubtractivePipe', pipeProperties, [ + partition('standard', 'Standard transformed pipe.', { Mode: 'Standard', Transition: 'Transformed', Transformation: 'Constant' }), + partition('frenet', 'Frenet pipe.', { Mode: 'Frenet', Transition: 'Round corner', Transformation: 'Constant' }), + partition('multisection', 'Multi-section pipe.', { Mode: 'Standard', Transition: 'Right corner', Transformation: 'Multisection' }), + ]), + family('PartDesign::Fillet', ['Base', 'Radius', 'UseAllEdges'], [ + partition('selected', 'Selected stable edges.', { Radius: 1, UseAllEdges: false }), + partition('all-edges', 'All edges.', { Radius: 1, UseAllEdges: true }), + partition('boundary', 'Small positive radius.', { Radius: Number.EPSILON, UseAllEdges: false }), + ]), + family('PartDesign::Chamfer', ['Base', 'Size', 'Size2', 'Angle', 'ChamferType', 'FlipDirection', 'UseAllEdges'], [ + partition('equal', 'Equal-distance chamfer.', { Size: 1, ChamferType: 'Equal distance' }), + partition('two-distances', 'Two-distance chamfer.', { Size: 1, Size2: 2, ChamferType: 'Two distances' }), + partition('distance-angle', 'Distance-and-angle chamfer.', { Size: 1, Angle: 45, ChamferType: 'Distance and Angle' }), + ]), + family('PartDesign::Draft', ['Base', 'Angle', 'Direction', 'NeutralPlaneOrigin', 'NeutralPlaneDirection', 'Reversed', 'UseAllFaces'], [ + partition('positive', 'Positive draft.', { Angle: 5, Reversed: false }), + partition('negative', 'Reverse draft.', { Angle: -5, Reversed: true }), + partition('all-faces', 'All supported faces.', { Angle: 5, UseAllFaces: true }), + ]), + family('PartDesign::Thickness', ['Base', 'Value', 'RemoveFaces', 'Join', 'Mode', 'Reversed'], [ + partition('arc', 'Arc join.', { Value: 1, Join: 'Arc', Mode: 'Skin', Reversed: false }), + partition('intersection', 'Intersection join.', { Value: 1, Join: 'Intersection', Mode: 'Skin', Reversed: false }), + partition('reversed', 'Reversed thickness.', { Value: 1, Join: 'Arc', Mode: 'Skin', Reversed: true }), + ]), + family('PartDesign::Mirrored', ['Base', 'Originals', 'TransformMode', 'Plane', 'PlaneOrigin', 'PlaneNormal', 'Fuse'], [ + partition('xy', 'Mirror on XY plane.', { Plane: 'XY plane', PlaneNormal: { x: 0, y: 0, z: 1 }, Fuse: true }), + partition('xz', 'Mirror on XZ plane.', { Plane: 'XZ plane', PlaneNormal: { x: 0, y: 1, z: 0 }, Fuse: true }), + partition('yz', 'Mirror on YZ plane.', { Plane: 'YZ plane', PlaneNormal: { x: 1, y: 0, z: 0 }, Fuse: false }), + ]), + family('PartDesign::MultiTransform', ['Base', 'Originals', 'TransformMode', 'Transformations'], [ + partition('linear-polar', 'Ordered linear and polar transforms.', { Transformations: { steps: [{ id: 'linear', type: 'linear', occurrences: 2, length: 10, direction: 'Horizontal' }, { id: 'polar', type: 'polar', occurrences: 3, angle: 180, axis: 'Normal' }] } }), + partition('linear-mirror', 'Ordered linear and mirrored transforms.', { Transformations: { steps: [{ id: 'linear', type: 'linear', occurrences: 2, length: 10, direction: 'Vertical' }, { id: 'mirror', type: 'mirrored', plane: 'YZ plane' }] } }), + partition('polar-mirror', 'Ordered polar and mirrored transforms.', { Transformations: { steps: [{ id: 'polar', type: 'polar', occurrences: 3, angle: 360, axis: 'Normal' }, { id: 'mirror', type: 'mirrored', plane: 'XY plane' }] } }), + ]), + family('PartDesign::LinearPattern', ['Base', 'Originals', 'TransformMode', 'Occurrences', 'Length', 'Offset', 'Direction', 'DirectionVector', 'Mode', 'Reversed', 'Spacings', 'SpacingPattern', 'Direction2', 'Mode2', 'Length2', 'Offset2', 'Occurrences2', 'Reversed2', 'Spacings2', 'SpacingPattern2'], [ + partition('extent', 'One-direction extent pattern.', { Occurrences: 2, Length: 20, Mode: 'Extent', Direction2: 'None' }), + partition('spacing', 'One-direction spacing pattern.', { Occurrences: 3, Offset: 5, Mode: 'Spacing', Direction2: 'None' }), + partition('two-directions', 'Two-direction rectangular pattern.', { Occurrences: 2, Length: 20, Mode: 'Extent', Direction2: 'Vertical', Occurrences2: 2, Length2: 10, Mode2: 'Extent' }), + ]), + family('PartDesign::PolarPattern', ['Base', 'Originals', 'TransformMode', 'Occurrences', 'Angle', 'Axis', 'AxisOrigin', 'AxisDirection', 'Mode', 'Offset', 'Spacings', 'SpacingPattern', 'Reversed'], [ + partition('extent', 'Angular extent pattern.', { Occurrences: 3, Angle: 360, Mode: 'Extent' }), + partition('spacing', 'Fixed angular spacing.', { Occurrences: 3, Offset: 30, Mode: 'Spacing' }), + partition('reversed', 'Reversed angular pattern.', { Occurrences: 3, Angle: 180, Mode: 'Extent', Reversed: true }), + ]), + family('PartDesign::Hole', ['Base', 'Diameter', 'Depth', 'Type', 'DepthType', 'Position', 'Direction', 'Reversed', 'HoleCutType', 'HoleCutDiameter', 'HoleCutDepth', 'HoleCutCountersinkAngle', 'DrillPoint', 'DrillPointAngle', 'DrillForDepth', 'Tapered', 'TaperedAngle', 'HoleCutCustomValues', 'Threaded', 'ModelThread', 'ThreadType', 'ThreadSize', 'ThreadDiameter', 'ThreadPitch', 'ThreadClass', 'ThreadFit', 'ThreadDirection', 'ThreadDepthType', 'ThreadDepth', 'UseCustomThreadClearance', 'CustomThreadClearance'], [ + partition('dimension', 'Finite plain hole.', { DepthType: 'Dimension', Diameter: 5, Depth: 10, HoleCutType: 'None' }), + partition('through-all', 'Through-all hole.', { DepthType: 'ThroughAll', Diameter: 5, Depth: 10 }), + partition('counterbore', 'Counterbored hole.', { HoleCutType: 'Counterbore', HoleCutDiameter: 8, HoleCutDepth: 2 }), + partition('countersink', 'Countersunk hole.', { HoleCutType: 'Countersink', HoleCutDiameter: 8, HoleCutCountersinkAngle: 90 }), + partition('threaded', 'Thread metadata without modeled thread.', { Threaded: true, ModelThread: false, ThreadType: 'ISOMetricProfile', ThreadDiameter: 6, ThreadPitch: 1 }), + partition('modeled-thread', 'Modeled thread.', { Threaded: true, ModelThread: true, ThreadType: 'ISOMetricProfile', ThreadDiameter: 6, ThreadPitch: 1 }), + ]), +]) + +const byTypeId = new Map(PARTDESIGN_PARAMETER_SPACE.map((entry) => [entry.typeId, entry])) +const number = (values: Readonly>, name: string) => typeof values[name] === 'number' ? values[name] as number : undefined +const string = (values: Readonly>, name: string) => typeof values[name] === 'string' ? values[name] as string : undefined +const bool = (values: Readonly>, name: string) => values[name] === true +const vector = (values: Readonly>, name: string) => { + const value = values[name] + return value && typeof value === 'object' && !Array.isArray(value) && 'x' in value && 'y' in value && 'z' in value ? value as VectorValue : undefined +} + +const push = (issues: PartDesignParameterIssue[], code: PartDesignParameterIssue['code'], propertyName: string, message: string) => issues.push({ code, propertyName, message }) +const positive = (issues: PartDesignParameterIssue[], values: Readonly>, name: string) => { + const value = number(values, name) + if (value === undefined || !Number.isFinite(value) || value <= 0) push(issues, 'INVALID_RANGE', name, `${name} must be finite and greater than zero.`) +} +const angle = (issues: PartDesignParameterIssue[], values: Readonly>, name: string, allowZero = false) => { + const value = number(values, name) + if (value === undefined || !Number.isFinite(value) || value < (allowZero ? 0 : Number.EPSILON) || value > 360) push(issues, 'INVALID_RANGE', name, `${name} must be within ${allowZero ? '[0, 360]' : '(0, 360]'} degrees.`) +} +const nonZeroVector = (issues: PartDesignParameterIssue[], values: Readonly>, name: string) => { + const value = vector(values, name) + if (!value || ![value.x, value.y, value.z].every(Number.isFinite) || Math.hypot(value.x, value.y, value.z) <= 0) push(issues, 'INVALID_VECTOR', name, `${name} must be a finite non-zero vector.`) +} + +export const parameterValuesForObject = (object: Pick): Record => Object.fromEntries(object.properties.filter((property) => property.scope === 'data').map((property) => [property.name, property.value])) + +export const validatePartDesignParameterSet = ( + typeId: string, + values: Readonly>, + options: { requireComplete?: boolean } = {}, +): PartDesignParameterValidation => { + const definition = byTypeId.get(typeId) + if (!definition) return { typeId, covered: false, valid: true, issues: [] } + const issues: PartDesignParameterIssue[] = [] + const requireComplete = options.requireComplete !== false + const has = (name: string) => Object.prototype.hasOwnProperty.call(values, name) + const checkPositive = (name: string) => { if (requireComplete || has(name)) positive(issues, values, name) } + const checkAngle = (name: string, allowZero = false) => { if (requireComplete || has(name)) angle(issues, values, name, allowZero) } + const checkVector = (name: string) => { if (requireComplete || has(name)) nonZeroVector(issues, values, name) } + if (requireComplete) for (const name of definition.properties) if (!has(name)) push(issues, 'MISSING_PROPERTY', name, `${typeId} is missing parameter ${name}.`) + + if (typeId === 'PartDesign::Pad' || typeId === 'PartDesign::Pocket') { + const mode = string(values, 'Type') + if (mode === 'Dimension' || mode === 'TwoLengths') checkPositive('Length') + if (mode === 'TwoLengths' || string(values, 'SideType') === 'Two sides') checkPositive('Length2') + if (mode === 'Up to face' && values.UpToFace === null) push(issues, 'INVALID_REFERENCE', 'UpToFace', 'Up-to-face mode requires a stable face reference.') + if (bool(values, 'Midplane') && (mode === 'TwoLengths' || string(values, 'SideType') === 'Two sides')) push(issues, 'INVALID_COMBINATION', 'Midplane', 'Midplane cannot be combined with independent two-sided lengths.') + for (const name of ['TaperAngle', 'TaperAngle2']) { + const value = number(values, name) + if (value !== undefined && (!Number.isFinite(value) || Math.abs(value) >= 90)) push(issues, 'INVALID_RANGE', name, `${name} must be strictly between -90 and 90 degrees.`) + } + if (bool(values, 'UseCustomVector')) checkVector('Direction') + } + if (typeId === 'PartDesign::Revolution' || typeId === 'PartDesign::Groove') { + checkAngle('Angle') + if (string(values, 'Type') === 'Two angles') { + checkAngle('Angle2') + if ((number(values, 'Angle') ?? 0) + (number(values, 'Angle2') ?? 0) > 360) push(issues, 'INVALID_COMBINATION', 'Angle2', 'Angle and Angle2 must total no more than 360 degrees.') + if (bool(values, 'Midplane')) push(issues, 'INVALID_COMBINATION', 'Midplane', 'Midplane cannot be combined with Two angles.') + } + } + if (typeId.endsWith('Loft')) { + const sections = [...(typeof values.Profile === 'string' ? [values.Profile] : []), ...(Array.isArray(values.Sections) ? values.Sections.filter((entry): entry is string => typeof entry === 'string') : [])] + if ((requireComplete || has('Profile') || has('Sections')) && (sections.length < 2 || new Set(sections).size !== sections.length)) push(issues, 'INVALID_REFERENCE', 'Sections', 'Loft requires at least two unique section profiles.') + } + if (typeId.endsWith('Pipe')) { + const profile = string(values, 'Profile') + const spineValue = values.Spine + const spine = typeof spineValue === 'string' ? spineValue : spineValue && typeof spineValue === 'object' && !Array.isArray(spineValue) && 'objectId' in spineValue ? String(spineValue.objectId) : undefined + if ((requireComplete || has('Profile') || has('Spine')) && (!profile || !spine || profile === spine)) push(issues, 'INVALID_REFERENCE', 'Spine', 'Pipe requires distinct Profile and Spine references.') + } + if (typeId === 'PartDesign::Fillet') checkPositive('Radius') + if (typeId === 'PartDesign::Chamfer') { + checkPositive('Size') + if (string(values, 'ChamferType') === 'Two distances') checkPositive('Size2') + if (string(values, 'ChamferType') === 'Distance and Angle') checkAngle('Angle') + } + if (typeId === 'PartDesign::Draft') { + const value = number(values, 'Angle') + if ((requireComplete || has('Angle')) && (value === undefined || !Number.isFinite(value) || value === 0 || Math.abs(value) >= 90)) push(issues, 'INVALID_RANGE', 'Angle', 'Draft Angle must be non-zero and strictly between -90 and 90 degrees.') + checkVector('Direction') + checkVector('NeutralPlaneDirection') + } + if (typeId === 'PartDesign::Thickness') checkPositive('Value') + if (typeId === 'PartDesign::Mirrored') checkVector('PlaneNormal') + if (typeId === 'PartDesign::LinearPattern') { + const occurrences = number(values, 'Occurrences') + if ((requireComplete || has('Occurrences')) && (!Number.isSafeInteger(occurrences) || (occurrences ?? 0) < 2 || (occurrences ?? 0) > 100)) push(issues, 'INVALID_RANGE', 'Occurrences', 'Occurrences must be an integer between 2 and 100.') + if (string(values, 'Mode') === 'Extent') checkPositive('Length') + else if (has('Mode') || requireComplete) checkPositive('Offset') + checkVector('DirectionVector') + if (string(values, 'Direction2') !== 'None') { + const occurrences2 = number(values, 'Occurrences2') + if ((requireComplete || has('Occurrences2')) && (!Number.isSafeInteger(occurrences2) || (occurrences2 ?? 0) < 2 || (occurrences2 ?? 0) > 100)) push(issues, 'INVALID_RANGE', 'Occurrences2', 'Occurrences2 must be an integer between 2 and 100 when the second direction is enabled.') + if (string(values, 'Mode2') === 'Extent') checkPositive('Length2') + else if (has('Mode2') || requireComplete) checkPositive('Offset2') + } + } + if (typeId === 'PartDesign::PolarPattern') { + const occurrences = number(values, 'Occurrences') + if ((requireComplete || has('Occurrences')) && (!Number.isSafeInteger(occurrences) || (occurrences ?? 0) < 2 || (occurrences ?? 0) > 100)) push(issues, 'INVALID_RANGE', 'Occurrences', 'Occurrences must be an integer between 2 and 100.') + if (string(values, 'Mode') === 'Extent') checkAngle('Angle') + else if (has('Mode') || requireComplete) checkAngle('Offset') + checkVector('AxisDirection') + } + if (typeId === 'PartDesign::Hole') { + checkPositive('Diameter') + if (string(values, 'DepthType') === 'Dimension') checkPositive('Depth') + checkVector('Direction') + if (string(values, 'HoleCutType') !== 'None' && (has('HoleCutType') || requireComplete)) checkPositive('HoleCutDiameter') + if (string(values, 'HoleCutType') === 'Counterbore' || string(values, 'HoleCutType') === 'Counterdrill') checkPositive('HoleCutDepth') + if (string(values, 'HoleCutType') === 'Countersink' || string(values, 'HoleCutType') === 'Counterdrill') checkAngle('HoleCutCountersinkAngle') + if (bool(values, 'Threaded')) { + if (!string(values, 'ThreadType') || string(values, 'ThreadType') === 'None') push(issues, 'INVALID_COMBINATION', 'ThreadType', 'Threaded holes require a thread standard.') + checkPositive('ThreadDiameter') + checkPositive('ThreadPitch') + } + if (bool(values, 'ModelThread') && !bool(values, 'Threaded')) push(issues, 'INVALID_COMBINATION', 'ModelThread', 'ModelThread requires Threaded.') + } + return { typeId, covered: true, valid: issues.length === 0, issues } +} + +export const assertPartDesignParameterSet = (object: Pick): void => { + const report = validatePartDesignParameterSet(object.typeId, parameterValuesForObject(object)) + if (!report.valid) throw new RangeError(`${object.typeId}.${report.issues[0].propertyName}: ${report.issues[0].message}`) +} + +export const partDesignParameterSpaceCoverage = () => ({ + schemaVersion: 1 as const, + baseline: 'FreeCAD 1.1.1' as const, + families: PARTDESIGN_PARAMETER_SPACE.length, + properties: PARTDESIGN_PARAMETER_SPACE.reduce((total, entry) => total + entry.properties.length, 0), + partitions: PARTDESIGN_PARAMETER_SPACE.reduce((total, entry) => total + entry.partitions.length, 0), + duplicateTypeIds: PARTDESIGN_PARAMETER_SPACE.length - new Set(PARTDESIGN_PARAMETER_SPACE.map((entry) => entry.typeId)).size, + familiesWithoutPartitions: PARTDESIGN_PARAMETER_SPACE.filter((entry) => entry.partitions.length < 3).map((entry) => entry.typeId), +}) diff --git a/src/facade/runtimeProfile.ts b/src/facade/runtimeProfile.ts index 1d77b06..cf8e9c6 100644 --- a/src/facade/runtimeProfile.ts +++ b/src/facade/runtimeProfile.ts @@ -12,7 +12,7 @@ export type FacadeRuntimeProfile = { availability: 'configured-at-application-boundary' | 'not-configured' } naming: { - nativeAbi: 'not-exposed' + nativeAbi: 'freecad-private-v1-optional-worker' | 'not-exposed' preservation: 'opaque-preserved' } persistence: 'sqlite-opfs-with-memory-fallback' @@ -31,7 +31,7 @@ export const createFacadeRuntimeProfile = (mode: FacadeRuntimeMode): FacadeRunti availability: 'not-configured', }, naming: { - nativeAbi: 'not-exposed', + nativeAbi: mode === 'production' ? 'freecad-private-v1-optional-worker' : 'not-exposed', preservation: 'opaque-preserved', }, persistence: 'sqlite-opfs-with-memory-fallback', diff --git a/src/facade/sketcher.ts b/src/facade/sketcher.ts index 0468218..752b315 100644 --- a/src/facade/sketcher.ts +++ b/src/facade/sketcher.ts @@ -8,6 +8,9 @@ export type SketchGeometry = | { id: string; type: 'circle'; center: SketchPoint; radius: number; construction?: boolean } | { id: string; type: 'arc'; center: SketchPoint; radius: number; startAngle: number; endAngle: number; construction?: boolean } | { id: string; type: 'ellipse'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; construction?: boolean } + | { id: string; type: 'arcEllipse'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; startAngle: number; endAngle: number; construction?: boolean } + | { id: string; type: 'arcHyperbola'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; startAngle: number; endAngle: number; construction?: boolean } + | { id: string; type: 'arcParabola'; center: SketchPoint; focal: number; rotation: number; startAngle: number; endAngle: number; construction?: boolean } | { id: string; type: 'bspline'; degree: number; controlPoints: SketchPoint[]; weights?: number[]; knots?: number[]; periodic?: boolean; construction?: boolean } export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' } @@ -43,7 +46,11 @@ export type SketchConstraint = | { id: string; type: 'weight'; geometryId: string; controlPointIndex: number; value: number; driving?: boolean } | { id: string; type: 'snellsLaw'; first: SketchPointRef; second: SketchPointRef; boundaryGeometryId: string; value: number; driving?: boolean } | { id: string; type: 'snellsLaw'; firstGeometryId: string; secondGeometryId: string; value: number; driving?: boolean } - | { id: string; type: 'internalAlignment'; geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'bspline-control-point' | 'bspline-knot'; driving?: boolean } + | { id: string; type: 'internalAlignment'; geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'hyperbola-major' | 'hyperbola-minor' | 'hyperbola-focus' | 'parabola-focus' | 'parabola-focal-axis' | 'bspline-control-point' | 'bspline-knot'; driving?: boolean } + +export const FREECAD_SKETCHER_GEOMETRY_TYPES = Object.freeze(['point', 'line', 'arc', 'circle', 'ellipse', 'arcEllipse', 'arcHyperbola', 'arcParabola', 'bspline'] as const) +export const FREECAD_SKETCHER_CONSTRAINT_TYPES = Object.freeze(['coincident', 'horizontal', 'vertical', 'parallel', 'tangent', 'distance', 'distanceX', 'distanceY', 'angle', 'perpendicular', 'radius', 'equal', 'pointOnObject', 'symmetric', 'internalAlignment', 'snellsLaw', 'block', 'diameter', 'weight'] as const) +export const FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES = Object.freeze(['ellipse-major', 'ellipse-minor', 'ellipse-focus', 'hyperbola-major', 'hyperbola-minor', 'hyperbola-focus', 'parabola-focus', 'parabola-focal-axis', 'bspline-control-point', 'bspline-knot'] as const) export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid' @@ -97,10 +104,17 @@ export const validateSketchGeometry = (geometry: SketchGeometry): void => { if (!Number.isFinite(geometry.radius) || geometry.radius <= 0) throw new RangeError(`${geometry.id} radius must be finite and greater than zero.`) if (geometry.type === 'arc' && (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle))) throw new RangeError(`${geometry.id} angles must be finite.`) } - if (geometry.type === 'ellipse') { + if (geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola') { assertFinitePoint(geometry.center, `${geometry.id}.center`) if (!Number.isFinite(geometry.majorRadius) || geometry.majorRadius <= 0 || !Number.isFinite(geometry.minorRadius) || geometry.minorRadius <= 0) throw new RangeError(`${geometry.id} radii must be finite and greater than zero.`) - if (geometry.majorRadius < geometry.minorRadius || !Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid axis parameters.`) + if ((geometry.type === 'ellipse' || geometry.type === 'arcEllipse') && geometry.majorRadius < geometry.minorRadius) throw new RangeError(`${geometry.id} has invalid axis parameters.`) + if (!Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid axis parameters.`) + if (geometry.type !== 'ellipse' && (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle) || geometry.startAngle === geometry.endAngle)) throw new RangeError(`${geometry.id} parameter range must contain two distinct finite values.`) + } + if (geometry.type === 'arcParabola') { + assertFinitePoint(geometry.center, `${geometry.id}.center`) + if (!Number.isFinite(geometry.focal) || geometry.focal <= 0 || !Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid parabola parameters.`) + if (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle) || geometry.startAngle === geometry.endAngle) throw new RangeError(`${geometry.id} parameter range must contain two distinct finite values.`) } if (geometry.type === 'bspline') { if (!Number.isSafeInteger(geometry.degree) || geometry.degree < 1) throw new RangeError(`${geometry.id} degree must be a positive integer.`) @@ -148,7 +162,7 @@ export const sketchGeometrySignature = (geometry: SketchGeometry): string => { export const cloneSketchGeometry = (geometry: SketchGeometry): SketchGeometry => { validateSketchGeometry(geometry) if (geometry.type === 'line') return { ...geometry, start: { ...geometry.start }, end: { ...geometry.end } } - if (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') return { ...geometry, center: { ...geometry.center } } + if (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola') return { ...geometry, center: { ...geometry.center } } if (geometry.type === 'bspline') return { ...geometry, controlPoints: geometry.controlPoints.map((point) => ({ ...point })), weights: geometry.weights ? [...geometry.weights] : undefined, knots: geometry.knots ? [...geometry.knots] : undefined } return { ...geometry, position: { ...geometry.position } } } @@ -530,7 +544,7 @@ const findGeometry = (geometry: SketchGeometry[], id: string, constraintId: stri const pointFor = (geometry: SketchGeometry, point: SketchPointRef['point'], constraintId: string, diagnostics: SketchDiagnostic[]): SketchPoint | null => { if (geometry.type === 'point' && point === 'position') return geometry.position if (geometry.type === 'line' && (point === 'start' || point === 'end')) return point === 'start' ? geometry.start : geometry.end - if ((geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') && point === 'center') return geometry.center + if ((geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola') && point === 'center') return geometry.center diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` }) return null } @@ -583,7 +597,7 @@ const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], n if (isBlocked(geometry.id, blocked)) return if (geometry.type === 'point') { geometry.position = { ...next }; return } if (geometry.type === 'line') { if (point === 'start') geometry.start = { ...next }; else if (point === 'end') geometry.end = { ...next }; return } - if (point === 'center' && (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse')) geometry.center = { ...next } + if (point === 'center' && (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola')) geometry.center = { ...next } } const validateConstraintValues = (constraint: SketchConstraint, diagnostics: SketchDiagnostic[]) => { @@ -684,7 +698,7 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions = const snapshot = cloneSketch(input) const diagnostics: SketchDiagnostic[] = [] for (const geometry of snapshot.geometry) { - if (geometry.type === 'ellipse' || geometry.type === 'bspline') diagnostics.push({ code: 'UNSUPPORTED_GEOMETRY', geometryId: geometry.id, message: `The typescript-basic solver does not solve ${geometry.type} geometry '${geometry.id}'.` }) + if (!['point', 'line', 'circle', 'arc'].includes(geometry.type)) diagnostics.push({ code: 'UNSUPPORTED_GEOMETRY', geometryId: geometry.id, message: `The typescript-basic solver does not solve ${geometry.type} geometry '${geometry.id}'.` }) } for (const constraint of snapshot.constraints) if (constraint.type === 'weight' || constraint.type === 'snellsLaw' || constraint.type === 'internalAlignment') diagnostics.push({ code: 'UNSUPPORTED_CONSTRAINT', constraintId: constraint.id, message: `The typescript-basic solver does not solve ${constraint.type} constraint '${constraint.id}'.` }) const geometryById = new Map(snapshot.geometry.map((geometry) => [geometry.id, geometry])) @@ -829,6 +843,8 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions = if (geometry.type === 'line') return 4 if (geometry.type === 'circle') return 3 if (geometry.type === 'arc' || geometry.type === 'ellipse') return 5 + if (geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola') return 7 + if (geometry.type === 'arcParabola') return 6 return geometry.controlPoints.length * 2 + (geometry.weights?.length ?? 0) } const variableCount = snapshot.geometry.reduce((count, geometry) => count + variableCountForGeometry(geometry), 0) diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 187600e..b568df2 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -17,9 +17,11 @@ import { runTopologyMutationReplay } from '../src/facade/topologyReplay' import { captureNativeTopologyHistory, captureNativeTopologyHistoryStages, captureSignatureTopologyHistory, composeNativeTopologyHistoryLineage } from '../src/facade/topologyHistory' import { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '../src/facade/nativeHistoryProvider' import { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, nativeNamingAbiCapabilities, type NativeOcctHistoryProvider } from '../src/facade/nativeHistoryProtocol' +import { probeFreeCadPrivateNamingAbi } from '../src/facade/nativeNamingAbi' import { NativeOcctHistoryWorkerProvider } from '../src/facade/nativeHistoryWorkerClient' import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy' -import { applySketchAutoConstraints, cloneSketch, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, type SketchGeometry } from '../src/facade/sketcher' +import { applySketchAutoConstraints, cloneSketch, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, FREECAD_SKETCHER_CONSTRAINT_TYPES, FREECAD_SKETCHER_GEOMETRY_TYPES, FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, type SketchGeometry } from '../src/facade/sketcher' +import { PARTDESIGN_PARAMETER_SPACE, partDesignParameterSpaceCoverage, validatePartDesignParameterSet } from '../src/facade/partDesignParameters' import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol' import { solvePlanegcsSubset, type PlanegcsWasmModule } from '../src/facade/planegcsAdapter' import { PlanegcsWorkerProvider } from '../src/facade/planegcsWorkerClient' @@ -87,7 +89,7 @@ test('production facade starts from an empty document unless an application boot assert.deepEqual(state.selectedObjectIds, []) assert.equal(facade.runtime.mode, 'production') assert.equal(facade.runtime.geometry.compatibility, 'none') - assert.equal(facade.runtime.naming.nativeAbi, 'not-exposed') + assert.equal(facade.runtime.naming.nativeAbi, 'freecad-private-v1-optional-worker') facade.geometry.dispose() }) @@ -684,7 +686,7 @@ test('native OCCT history protocol validates STEP context and isolates stale gen resultStep: objectStep, records: [{ relation: operation === 'cut' ? 'modified' : 'generated', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }], }), }) - assert.deepEqual(provider.capabilities(), { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text' }) + assert.deepEqual(provider.capabilities(), { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text', naming: { mappedNameRef: 'unavailable', stringHasher: 'unavailable', elementMap2: 'unavailable', tokenGeneration: 'forbidden', reason: 'Native module does not export the versioned FreeCAD naming callbacks.' } }) const coordinator = new NativeOcctHistoryCoordinator(() => 2) const execution = await coordinator.capture(provider, { documentId: 'doc', documentVersion: 2, operationId: 'cut-1', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }) assert.equal(execution.status, 'completed') @@ -699,8 +701,38 @@ test('native OCCT history protocol validates STEP context and isolates stale gen test('native naming ABI capabilities forbid synthetic FreeCAD tokens unless a provider declares native evidence', () => { const base = { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available' as const, operations: ['cut' as const], transport: 'step-text' as const } - assert.deepEqual(nativeNamingAbiCapabilities(base), { mappedNameRef: 'unavailable', stringHasher: 'unavailable', tokenGeneration: 'forbidden' }) - assert.deepEqual(nativeNamingAbiCapabilities({ ...base, naming: { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', tokenGeneration: 'native-only' } }), { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', tokenGeneration: 'native-only' }) + assert.deepEqual(nativeNamingAbiCapabilities(base), { mappedNameRef: 'unavailable', stringHasher: 'unavailable', elementMap2: 'unavailable', tokenGeneration: 'forbidden' }) + assert.deepEqual(nativeNamingAbiCapabilities({ ...base, naming: { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', elementMap2: 'opaque-preserved', tokenGeneration: 'native-only' } }), { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', elementMap2: 'opaque-preserved', tokenGeneration: 'native-only' }) +}) + +test('versioned FreeCAD private naming ABI is capability-gated and attaches validated native evidence', async () => { + const elementMap2 = parseElementMap2([ + 'BeginElementMap v1', '1 PostfixCount 1', 'Edge', 'MapCount 1', 'ElementMap 1 1 1', 'Edge', + 'ChildCount 1', '1 0 1 0 0 Edge 0.54', 'NameCount 1', '$#36:2.1.37 0', 'EndMap', '', + ].join('\n')) + const stringHasher = parseStringHasherTable(['StringTableStart v1 2', '-36.0 0:prefix', '-1.0 0:suffix', ''].join('\n')) + let capturedRequest: Record | undefined + const module = { + occtVersion: () => '8.0.0', + booleanHistoryFromStep: (objectStep: string) => ({ provider: 'occt-native' as const, occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, resultStep: objectStep, records: [{ relation: 'modified' as const, source: 'object' as const, kind: 'edge' as const, sourceIndex: 0, resultIndex: 0 }] }), + freecadNamingAbiVersion: () => 1, + 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 }) + }, + } + assert.deepEqual(probeFreeCadPrivateNamingAbi(module).availability, 'available') + const provider = new DirectNativeOcctHistoryProvider(module) + assert.deepEqual(provider.capabilities().naming, { mappedNameRef: 'available', stringHasher: 'available', elementMap2: 'available', tokenGeneration: 'native-only', abiVersion: 1, freecadVersion: '1.1.1', sourceCommit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d', operations: ['cut'] }) + const response = await provider.capture({ protocolVersion: 1, requestId: 'naming-1', documentId: 'doc', documentVersion: 2, operationId: 'cut-native', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }, new AbortController().signal) + assert.equal(response.history.namingEvidence?.status, 'native-evidence') + assert.equal(response.history.namingEvidence?.mappedNames?.[0].resultPersistentId, 'edge-native-0') + assert.equal(capturedRequest?.operation, 'cut') + assert.equal((capturedRequest?.history as { records: unknown[] }).records.length, 1) + + 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') }) test('native OCCT history coordinator reports timeout and cancellation', async () => { @@ -713,6 +745,27 @@ test('native OCCT history coordinator reports timeout and cancellation', async ( assert.equal((await pending).status, 'cancelled') }) +test('Sketcher and supported PartDesign parameter contracts cover every declared native partition', () => { + assert.deepEqual(FREECAD_SKETCHER_GEOMETRY_TYPES, ['point', 'line', 'arc', 'circle', 'ellipse', 'arcEllipse', 'arcHyperbola', 'arcParabola', 'bspline']) + assert.equal(FREECAD_SKETCHER_CONSTRAINT_TYPES.length, 19) + assert.equal(FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES.length, 10) + assert.equal(new Set(FREECAD_SKETCHER_CONSTRAINT_TYPES).size, FREECAD_SKETCHER_CONSTRAINT_TYPES.length) + assert.equal(new Set(FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES).size, FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES.length) + assert.deepEqual(partDesignParameterSpaceCoverage(), { schemaVersion: 1, baseline: 'FreeCAD 1.1.1', families: 21, properties: 202, partitions: 72, duplicateTypeIds: 0, familiesWithoutPartitions: [] }) + for (const family of PARTDESIGN_PARAMETER_SPACE) { + assert.equal(new Set(family.properties).size, family.properties.length, `${family.typeId} has duplicate properties`) + assert.equal(new Set(family.partitions.map((entry) => entry.id)).size, family.partitions.length, `${family.typeId} has duplicate partitions`) + for (const partition of family.partitions) for (const propertyName of Object.keys(partition.values)) assert.ok(family.properties.includes(propertyName), `${family.typeId}.${partition.id} uses undeclared ${propertyName}`) + } + + const validPad = validatePartDesignParameterSet('PartDesign::Pad', { Profile: 'Sketch', Length: 10, Length2: 5, Type: 'TwoLengths', Type2: 'Dimension', SideType: 'Two sides', UpToFace: null, UpToFace2: null, TaperAngle: 0, TaperAngle2: 0, Reversed: false, Midplane: false, ReferenceAxis: null, AlongSketchNormal: true, UseCustomVector: false, Direction: { x: 0, y: 0, z: 1 }, Offset: 0, Offset2: 0 }) + assert.equal(validPad.valid, true) + assert.equal(validatePartDesignParameterSet('PartDesign::Pad', { ...Object.fromEntries(PARTDESIGN_PARAMETER_SPACE.find((entry) => entry.typeId === 'PartDesign::Pad')!.properties.map((name) => [name, null])), Type: 'TwoLengths', SideType: 'Two sides', Length: 10, Length2: 5, Midplane: true, TaperAngle: 0, TaperAngle2: 0 }).valid, false) + assert.equal(validatePartDesignParameterSet('PartDesign::Hole', { Diameter: 5, DepthType: 'ThroughAll', Direction: { x: 0, y: 0, z: 1 }, HoleCutType: 'None', Threaded: false, ModelThread: true }, { requireComplete: false }).issues.some((issue) => issue.propertyName === 'ModelThread'), true) + assert.equal(validatePartDesignParameterSet('PartDesign::Draft', { Base: 'Pad', Angle: 5, Reversed: false }, { requireComplete: false }).valid, true) + assert.equal(validatePartDesignParameterSet('PartDesign::LinearPattern', { Originals: ['Pad'], Occurrences: 2, Mode: 'Extent', Length: 10, Direction: 'Horizontal' }, { requireComplete: false }).valid, true) +}) + test('native OCCT Worker provider preserves versioned request and response context', async () => { const messageListeners = new Set<(event: MessageEvent) => void>() const errorListeners = new Set<(event: ErrorEvent) => void>() @@ -4666,6 +4719,33 @@ test('FCStd Sketcher codec round-trips ellipse and B-spline knot internal geomet assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: invalidEllipseIndex }] }), /invalid ellipse internal index/) }) +test('FCStd Sketcher codec round-trips all conic arc geometry and private internal alignments', () => { + const sketch = createSketch('Sketch', [ + { id: 'arc-ellipse', type: 'arcEllipse', center: { x: 1, y: 2 }, majorRadius: 4, minorRadius: 2, rotation: 0.2, startAngle: -1, endAngle: 1 }, + { id: 'arc-hyperbola', type: 'arcHyperbola', center: { x: 8, y: 2 }, majorRadius: 3, minorRadius: 1.5, rotation: 0.4, startAngle: -0.8, endAngle: 0.8 }, + { id: 'arc-parabola', type: 'arcParabola', center: { x: 14, y: 2 }, focal: 2, rotation: 0.6, startAngle: -1.2, endAngle: 1.2 }, + ], [ + { id: 'ellipse-major', type: 'internalAlignment', geometryId: 'arc-ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-major' }, + { id: 'hyperbola-major', type: 'internalAlignment', geometryId: 'arc-hyperbola', internalGeometryIndex: 0, alignmentType: 'hyperbola-major' }, + { id: 'hyperbola-minor', type: 'internalAlignment', geometryId: 'arc-hyperbola', internalGeometryIndex: 0, alignmentType: 'hyperbola-minor' }, + { id: 'hyperbola-focus', type: 'internalAlignment', geometryId: 'arc-hyperbola', internalGeometryIndex: 0, alignmentType: 'hyperbola-focus' }, + { id: 'parabola-focus', type: 'internalAlignment', geometryId: 'arc-parabola', internalGeometryIndex: 0, alignmentType: 'parabola-focus' }, + { id: 'parabola-axis', type: 'internalAlignment', geometryId: 'arc-parabola', internalGeometryIndex: 0, alignmentType: 'parabola-focal-axis' }, + ]) + const document = recomputeDocumentFixture() + document.objects = [{ id: 'Sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch }] + const archive = serializeFcstdMetadataArchive(document) + const written = new TextDecoder().decode(unzipSync(archive)['Document.xml']) + assert.match(written, /type="Part::GeomArcOfEllipse"/) + assert.match(written, /type="Part::GeomArcOfHyperbola"/) + assert.match(written, /type="Part::GeomArcOfParabola"/) + assert.deepEqual([...written.matchAll(/InternalAlignmentType="(\d+)"/g)].map((match) => Number(match[1])), [1, 5, 6, 7, 8, 11]) + const restored = inspectFcstdArchive(archive).objects[0].sketch + assert.ok(restored) + assert.deepEqual(restored.geometry, sketch.geometry) + assert.deepEqual(restored.constraints, sketch.constraints.map((constraint) => ({ ...constraint, driving: true }))) +}) + test('FCStd Sketcher codec round-trips native external Edge/Vertex projections and stable TopoRefs', () => { const sketch = createSketch('Sketch', [{ id: 'profile', type: 'line', start: { x: 0, y: 0 }, end: { x: 2, y: 0 } }]) const edgeSource = { schemaVersion: 1 as const, objectId: 'Source', kind: 'edge' as const, persistentId: 'Edge1', topologyVersion: 7, generation: 4, status: 'stable' as const, signature: 'edge-signature', candidates: ['Edge1'] }