feat: complete supported Sketcher and PartDesign ABI contract
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled

This commit is contained in:
2026-08-11 20:34:23 -04:00
parent 2be18a511e
commit aa607451ad
20 changed files with 918 additions and 62 deletions

View File

@@ -97,7 +97,7 @@
"status": "explicit-runtime-contract", "status": "explicit-runtime-contract",
"mappedNameRef": "optional provider evidence; final-shape-only stages are marked and cannot mint FreeCAD tokens", "mappedNameRef": "optional provider evidence; final-shape-only stages are marked and cannot mint FreeCAD tokens",
"stringHasher": "optional opaque native table; validated and preserved losslessly", "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": { "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"] } "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": { "systemExactEvaluation": {
"evaluatedAt": "2026-08-10", "evaluatedAt": "2026-08-11",
"exact": false, "exact": false,
"featureExactCount": 0, "featureExactCount": 0,
"plan": "config/freecad-web-exact-parity-plan.json", "plan": "config/freecad-web-exact-parity-plan.json",
"promotionTask": "EX-REL-01", "promotionTask": "EX-REL-01",
"taskStatus": { "completed": 2, "inProgress": 36, "pending": 14, "blocked": 0 }, "taskStatus": { "completed": 2, "inProgress": 37, "pending": 13, "blocked": 0 },
"blockers": ["browser FreeCAD private token callback ABI", "browser builder naming evidence transport", "non-unique isomorphic source"] "blockers": ["shipped Worker lacks the FreeCAD-linked private naming ABI implementation", "browser builder naming evidence transport", "non-unique isomorphic source"]
}, },
"rules": [ "rules": [
"UI status does not imply geometry or file compatibility.", "UI status does not imply geometry or file compatibility.",

View File

@@ -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"
}
}

View File

@@ -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-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-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-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-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"] } { "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", "id": "EX04",
"title": "Sketcher, Part and PartDesign feature exactness", "title": "Sketcher, Part and PartDesign feature exactness",
"tasks": [ "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-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-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"] } { "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"] }
] ]
}, },

View File

@@ -22,20 +22,20 @@
"bytes": 556597, "bytes": 556597,
"sha256": "1679307fb0f01e9d7b9f37a081458bf44583e4d1899edf9224b49a1435ce7105" "sha256": "1679307fb0f01e9d7b9f37a081458bf44583e4d1899edf9224b49a1435ce7105"
}, },
{
"path": "assets/index-BcH6J3tB.js",
"bytes": 1103011,
"sha256": "870d33fae0ceabaf31a8100e65ad7f1afb3f4f869bfc225bb8c722d7771ca917"
},
{ {
"path": "assets/index-BnRqhr3m.css", "path": "assets/index-BnRqhr3m.css",
"bytes": 65976, "bytes": 65976,
"sha256": "b8eacf86224d2705e3c27367b3a0b166f4d662a5b8142d34a8abcdeef58753ec" "sha256": "b8eacf86224d2705e3c27367b3a0b166f4d662a5b8142d34a8abcdeef58753ec"
}, },
{ {
"path": "assets/nativeHistoryWorkerEntry-D9i46nqJ.js", "path": "assets/index-ChdGmY7w.js",
"bytes": 4664, "bytes": 1130165,
"sha256": "488e45728dd4ccfd65acb82c61266332171129a52e916ecbb7cc2a8ab61d4ed6" "sha256": "c508b701c47d2dea412c96f3a529f99b42bd7b137e647d958b993c3268dfc3e6"
},
{
"path": "assets/nativeHistoryWorkerEntry-gNsJxM2G.js",
"bytes": 20437,
"sha256": "39c26691ba3275ce5fbb3a43239d5511430bc0222259f0c76e3aa9314f2e87cf"
}, },
{ {
"path": "assets/persistenceWorker-CyD5EUVX.js", "path": "assets/persistenceWorker-CyD5EUVX.js",
@@ -43,9 +43,9 @@
"sha256": "65a24ca0125444e3047f409d3b1c3667321e392a0ca9ddf64df6b728a1544c66" "sha256": "65a24ca0125444e3047f409d3b1c3667321e392a0ca9ddf64df6b728a1544c66"
}, },
{ {
"path": "assets/planegcsWorkerEntry-BEpPJPEo.js", "path": "assets/planegcsWorkerEntry-BeAnOOBp.js",
"bytes": 26112, "bytes": 26910,
"sha256": "2b5b0a9604aa8181f38b803753ad42e33e6c426a9fd8c1df013ae8762c6bb5c0" "sha256": "c2253b714352c02e543fa97dff1c1e7c64ae3983469d4e5a6b9bf75b78ba3483"
}, },
{ {
"path": "assets/sqlite3-BVKGSWc-.wasm", "path": "assets/sqlite3-BVKGSWc-.wasm",
@@ -330,7 +330,7 @@
{ {
"path": "index.html", "path": "index.html",
"bytes": 678, "bytes": 678,
"sha256": "07fab5a0e83597c5ca0a0cd2d57625f0134b56c02f2349a35be4e6de23540d33" "sha256": "ce1c0c151dfb290cff01268efaec2efd0cdc354548ffccb5cb1030b02249870f"
}, },
{ {
"path": "manifest.webmanifest", "path": "manifest.webmanifest",
@@ -380,7 +380,7 @@
{ {
"path": "sw.js", "path": "sw.js",
"bytes": 1390, "bytes": 1390,
"sha256": "01593ec533053a9ccdf341ef276ee5c0e1752e6dc30951acdc042c33d60f206c" "sha256": "a8d35592358f8580b3be765a36897969c63ebfb7c777a71296481e0d8118fad7"
}, },
{ {
"path": "vendor/camotics/camotics-sweep.wasm", "path": "vendor/camotics/camotics-sweep.wasm",
@@ -402,7 +402,7 @@
"status": "signed", "status": "signed",
"algorithm": "Ed25519", "algorithm": "Ed25519",
"keyId": "bitbybit-local-release-2026", "keyId": "bitbybit-local-release-2026",
"payloadSha256": "f1fe903dc9879b0f02d1a4becbd5daf7fb9d7b96ef304f2cc04fc5058d80256e", "payloadSha256": "be48e099205c1a86339eb4560e62ed88e12ffd0e2e45e036f3a723abd3bb078a",
"signature": "RY1mpA6T/zWYx1/Z/9Z7A2kpkBbbCbP8TQIIykaFbY2E8qzmlAw0qotNYq3yOP5OfbJqas+BjAXWJeEY7WA4DQ==" "signature": "AAyAo8hzt1I48LaHUArN7lEg11zWrl3EkNKF6zr92x2SZHFhXq/5m8KZ0hqJxNU4INM6bC/laqanCJ9GhRbkCQ=="
} }
} }

View File

@@ -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` 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 代替浏览器实时私有命名回调。 真实 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 状态不变。

View File

@@ -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` 候选,校验失败直接阻断写回。 运行时命名证据必须来自 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、以及无唯一同构来源。 逐 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 ### 21. 全功能 exact 计划2026-08-09

View File

@@ -61,7 +61,8 @@
"build:freecad-native": "node scripts/build-freecad-native.mjs", "build:freecad-native": "node scripts/build-freecad-native.mjs",
"check:freecad-desktop-oracle": "node scripts/check-freecad-desktop-oracle.mjs", "check:freecad-desktop-oracle": "node scripts/check-freecad-desktop-oracle.mjs",
"probe:freecad-sketcher-constraints": "node scripts/run-freecad-sketcher-constraint-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", "probe:freecad-partdesign-profiles": "node scripts/run-freecad-partdesign-profile-oracle.mjs",
"check:freecad-partdesign-profiles": "node scripts/check-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", "probe:freecad-partdesign-base": "node scripts/run-freecad-partdesign-base-oracle.mjs",

View File

@@ -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') 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 (/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') 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}'`) if (!runtimeProfile.includes(declaration)) violations.push(`facade/runtimeProfile.ts: missing explicit runtime boundary '${declaration}'`)
} }

View File

@@ -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}`)

View File

@@ -334,6 +334,26 @@ const nativeDataPropertyNames = new Map<string, ReadonlySet<string>>([
['Part::Revolution', new Set(['Source', 'Base', 'Axis', 'AxisLink', 'Angle', 'Symmetric', 'Solid', 'FaceMakerClass'])], ['Part::Revolution', new Set(['Source', 'Base', 'Axis', 'AxisLink', 'Angle', 'Symmetric', 'Solid', 'FaceMakerClass'])],
['Part::Loft', new Set(['Sections', 'Solid', 'Ruled', 'Closed', 'MaxDegree', 'Linearize'])], ['Part::Loft', new Set(['Sections', 'Solid', 'Ruled', 'Closed', 'MaxDegree', 'Linearize'])],
['Part::Sweep', new Set(['Sections', 'Spine', 'Solid', 'Frenet', 'Transition', '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'])], ['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::Fuse', new Set(['Base', 'Tool', 'Refine'])],
['Part::Cut', new Set(['Base', 'Tool', 'Refine'])], ['Part::Cut', new Set(['Base', 'Tool', 'Refine'])],
@@ -384,6 +404,9 @@ const sketchGeometryType = {
circle: 'Part::GeomCircle', circle: 'Part::GeomCircle',
arc: 'Part::GeomArcOfCircle', arc: 'Part::GeomArcOfCircle',
ellipse: 'Part::GeomEllipse', ellipse: 'Part::GeomEllipse',
arcEllipse: 'Part::GeomArcOfEllipse',
arcHyperbola: 'Part::GeomArcOfHyperbola',
arcParabola: 'Part::GeomArcOfParabola',
bspline: 'Part::GeomBSplineCurve', bspline: 'Part::GeomBSplineCurve',
} as const } as const
@@ -393,6 +416,9 @@ const sketchGeometryPayloadXml = (geometry: SketchGeometry) => {
if (geometry.type === 'circle') return `<Circle CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" AngleXU="0" Radius="${geometry.radius}"/>` if (geometry.type === 'circle') return `<Circle CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" AngleXU="0" Radius="${geometry.radius}"/>`
if (geometry.type === 'arc') return `<ArcOfCircle CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" AngleXU="0" Radius="${geometry.radius}" StartAngle="${geometry.startAngle}" EndAngle="${geometry.endAngle}"/>` if (geometry.type === 'arc') return `<ArcOfCircle CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" AngleXU="0" Radius="${geometry.radius}" StartAngle="${geometry.startAngle}" EndAngle="${geometry.endAngle}"/>`
if (geometry.type === 'ellipse') return `<Ellipse CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" MajorRadius="${geometry.majorRadius}" MinorRadius="${geometry.minorRadius}" AngleXU="${geometry.rotation}"/>` if (geometry.type === 'ellipse') return `<Ellipse CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" MajorRadius="${geometry.majorRadius}" MinorRadius="${geometry.minorRadius}" AngleXU="${geometry.rotation}"/>`
if (geometry.type === 'arcEllipse') return `<ArcOfEllipse CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" MajorRadius="${geometry.majorRadius}" MinorRadius="${geometry.minorRadius}" AngleXU="${geometry.rotation}" StartAngle="${geometry.startAngle}" EndAngle="${geometry.endAngle}"/>`
if (geometry.type === 'arcHyperbola') return `<ArcOfHyperbola CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" MajorRadius="${geometry.majorRadius}" MinorRadius="${geometry.minorRadius}" AngleXU="${geometry.rotation}" StartAngle="${geometry.startAngle}" EndAngle="${geometry.endAngle}"/>`
if (geometry.type === 'arcParabola') return `<ArcOfParabola CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" Focal="${geometry.focal}" AngleXU="${geometry.rotation}" StartAngle="${geometry.startAngle}" EndAngle="${geometry.endAngle}"/>`
const weights = geometry.weights ?? geometry.controlPoints.map(() => 1) const weights = geometry.weights ?? geometry.controlPoints.map(() => 1)
const knotCount = geometry.controlPoints.length + geometry.degree + 1 const knotCount = geometry.controlPoints.length + geometry.degree + 1
const knots = geometry.knots ?? Array.from({ length: knotCount }, (_, index) => { const knots = geometry.knots ?? Array.from({ length: knotCount }, (_, index) => {
@@ -411,7 +437,7 @@ const sketchGeometryPayloadXml = (geometry: SketchGeometry) => {
return `<BSplineCurve PolesCount="${geometry.controlPoints.length}" KnotsCount="${uniqueKnots.length}" Degree="${geometry.degree}" IsPeriodic="${geometry.periodic ? 1 : 0}">${poles}${knotXml}</BSplineCurve>` return `<BSplineCurve PolesCount="${geometry.controlPoints.length}" KnotsCount="${uniqueKnots.length}" Degree="${geometry.degree}" IsPeriodic="${geometry.periodic ? 1 : 0}">${poles}${knotXml}</BSplineCurve>`
} }
type SketchInternalAlignmentType = 1 | 2 | 3 | 4 | 9 | 10 type SketchInternalAlignmentType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11
type SketchInternalGeometryHelper = { type SketchInternalGeometryHelper = {
key: string key: string
@@ -533,8 +559,8 @@ const sketchInternalGeometryPlan = (sketch: SketchSnapshot): SketchInternalGeome
firstPosition: 1, firstPosition: 1,
geometry: { id: `Internal${helpers.size}`, type: 'point', position: bsplinePointAtParameter(target, uniqueKnots[constraint.internalGeometryIndex]), construction: true }, geometry: { id: `Internal${helpers.size}`, type: 'point', position: bsplinePointAtParameter(target, uniqueKnots[constraint.internalGeometryIndex]), construction: true },
}) })
} else { } else if (constraint.alignmentType.startsWith('ellipse-')) {
if (target.type !== 'ellipse') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires ellipse geometry '${constraint.geometryId}'.`) 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 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.`) 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 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: '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 } : { 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 }) 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}.`) if (helper.explicitAlignmentId) throw new Error(`FCStd native Sketch has duplicate InternalAlignment constraints for '${constraint.geometryId}' internal geometry ${constraint.internalGeometryIndex}.`)
helper.explicitAlignmentId = constraint.id helper.explicitAlignmentId = constraint.id
@@ -746,7 +797,7 @@ const sketchConstraintPoint = (geometryIndex: Map<string, number>, geometryById:
if (!geometry) throw new RangeError(`FCStd Sketch ${context} references unknown geometry '${reference.geometryId}'.`) if (!geometry) throw new RangeError(`FCStd Sketch ${context} references unknown geometry '${reference.geometryId}'.`)
const valid = geometry.type === 'line' ? reference.point === 'start' || reference.point === 'end' const valid = geometry.type === 'line' ? reference.point === 'start' || reference.point === 'end'
: geometry.type === 'point' ? reference.point === 'position' : 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 : false
if (!valid) throw new RangeError(`FCStd Sketch ${context} point '${reference.point}' is invalid for ${geometry.type} '${geometry.id}'.`) 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) 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::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::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::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') { else if (type === 'Part::GeomBSplineCurve') {
const curve = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {} const curve = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
const poles = asArray(curve.Pole as Record<string, unknown> | Record<string, unknown>[] | undefined) const poles = asArray(curve.Pole as Record<string, unknown> | Record<string, unknown>[] | undefined)
@@ -1676,7 +1730,7 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS
const candidate = nativeGeometryForNativeId(nativeId) const candidate = nativeGeometryForNativeId(nativeId)
return candidate && !candidate.freecadInternalType ? candidate : undefined 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<number, InternalHelperBinding>() const internalHelperBindings = new Map<number, InternalHelperBinding>()
for (const record of constraints) { for (const record of constraints) {
if (record.type !== 15) continue 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 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 let binding: InternalHelperBinding | undefined
if (record.internalAlignmentType === 1 || record.internalAlignmentType === 2) { 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' } binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: record.internalAlignmentType === 1 ? 'ellipse-major' : 'ellipse-minor' }
} else if (record.internalAlignmentType === 3 || record.internalAlignmentType === 4) { } 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' } 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) { } 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 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' } 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]) const uniqueKnots = bsplineExpandedKnots(target).filter((knot, index, knots) => index === 0 || knot !== knots[index - 1])
if (record.internalAlignmentIndex < 0 || record.internalAlignmentIndex >= uniqueKnots.length) return undefined if (record.internalAlignmentIndex < 0 || record.internalAlignmentIndex >= uniqueKnots.length) return undefined
binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentIndex, alignmentType: 'bspline-knot' } 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 } else return undefined
const existing = internalHelperBindings.get(helperNativeId) 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.`) 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] 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 === '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 === '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 return undefined
} }
const geometryRef = (record: typeof constraints[number], slot: number) => geometryForNativeId(record.ids[slot])?.id const geometryRef = (record: typeof constraints[number], slot: number) => geometryForNativeId(record.ids[slot])?.id

View File

@@ -77,14 +77,18 @@ export { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '
export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStage, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider' export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStage, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOcctHistoryUnavailableError, UnavailableNativeOcctHistoryProvider } from './nativeHistoryProtocol' export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOcctHistoryUnavailableError, UnavailableNativeOcctHistoryProvider } from './nativeHistoryProtocol'
export { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } 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 type { NativeNamingAbiCapabilities, NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryInputTransport, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest, NativeOcctHistoryStageTransport } from './nativeHistoryProtocol'
export { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest, FREECAD_PRIVATE_NAMING_ABI_VERSION, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi'
export type { FreeCadPrivateNamingAbiDescriptor, FreeCadPrivateNamingAbiProbe, FreeCadPrivateNamingAbiRequest, NativeFreeCadNamingAbiModule } from './nativeNamingAbi'
export { assertNativeNamingEvidence, createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, hasNativeMappedNameEvidence, validateNativeNamingEvidence } from './nativeNamingEvidence' export { assertNativeNamingEvidence, createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, hasNativeMappedNameEvidence, validateNativeNamingEvidence } from './nativeNamingEvidence'
export type { NativeMappedNameRef, NativeMappedNameRelation, NativeNamingEvidenceIssue, NativeNamingEvidenceReport, NativeNamingEvidenceStatus, NativeStageNamingEvidence } from './nativeNamingEvidence' export type { NativeMappedNameRef, NativeMappedNameRelation, NativeNamingEvidenceIssue, NativeNamingEvidenceReport, NativeNamingEvidenceStatus, NativeStageNamingEvidence } from './nativeNamingEvidence'
export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient' export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient'
export type { NativeOcctHistoryWorkerOptions } 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 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 { 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 type { SketchSolverCapabilities, SketchSolverCompatibility, SketchSolverExecution, SketchSolverProvider, SketchSolverReplayCase, SketchSolverReplayResult, SketchSolverRequest, SketchSolverResponse } from './sketchSolverProtocol'
export { PLANEGCS_WASM_CAPABILITIES, PlanegcsSubsetError, solvePlanegcsSubset } from './planegcsAdapter' export { PLANEGCS_WASM_CAPABILITIES, PlanegcsSubsetError, solvePlanegcsSubset } from './planegcsAdapter'

View File

@@ -46,6 +46,7 @@ import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replac
import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences' import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences'
import { createCamJob } from './cam' import { createCamJob } from './cam'
import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates' 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' 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: '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: '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: '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: '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: '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: '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: '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: '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 [ 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: '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: '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: '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: '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: '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: '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: '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: '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: '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: '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: '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 [ if (item.id.startsWith('revolution')) return [
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true }, { 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: '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: '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: '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: '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: '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 [ if (item.id.startsWith('groove')) return [
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true }, { 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: '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: '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: '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: '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: '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 [ if (item.id.startsWith('fillet')) return [
{ name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true }, { 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), ...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: '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: '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: '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: '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 }, { 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: '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: '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: '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 }, { name: 'Reversed', label: 'Reversed', group: 'Thickness', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
...viewProperties(), ...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.') 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) 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 === '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 === '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') { if (commandId === 'hole') {
@@ -1033,6 +1066,8 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
const document = cloneDocumentSnapshot(state.document) const document = cloneDocumentSnapshot(state.document)
const object = document.objects[objectIndex] const object = document.objects[objectIndex]
object.properties[propertyIndex] = { ...object.properties[propertyIndex], value: clonePropertyValue(value), expression: undefined, expressionError: undefined } 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) const treeItem = document.tree.find((item) => item.id === objectId)
if (propertyName === 'Label' && treeItem) treeItem.label = String(value) if (propertyName === 'Label' && treeItem) treeItem.label = String(value)
document.dependencies = collectDependencyEdges(document) document.dependencies = collectDependencyEdges(document)

View File

@@ -1,16 +1,24 @@
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctHistoryStepProvider, NativeOcctMultiTransformStep } from './nativeHistoryProvider' 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 const NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1 as const
export type NativeNamingAbiCapabilities = { export type NativeNamingAbiCapabilities = {
mappedNameRef: 'available' | 'optional' | 'unavailable' mappedNameRef: 'available' | 'optional' | 'unavailable'
stringHasher: 'available' | 'opaque-preserved' | 'unavailable' stringHasher: 'available' | 'opaque-preserved' | 'unavailable'
elementMap2: 'available' | 'opaque-preserved' | 'unavailable'
tokenGeneration: 'native-only' | 'forbidden' tokenGeneration: 'native-only' | 'forbidden'
abiVersion?: number
freecadVersion?: string
sourceCommit?: string
operations?: NativeOcctHistoryOperation[]
reason?: string
} }
export const NATIVE_OCCT_NAMING_ABI_UNAVAILABLE: NativeNamingAbiCapabilities = Object.freeze({ export const NATIVE_OCCT_NAMING_ABI_UNAVAILABLE: NativeNamingAbiCapabilities = Object.freeze({
mappedNameRef: 'unavailable', mappedNameRef: 'unavailable',
stringHasher: 'unavailable', stringHasher: 'unavailable',
elementMap2: 'unavailable',
tokenGeneration: 'forbidden', tokenGeneration: 'forbidden',
}) })
@@ -29,8 +37,24 @@ export type NativeOcctHistoryCapabilities = {
export const nativeNamingAbiCapabilities = (capabilities: NativeOcctHistoryCapabilities): NativeNamingAbiCapabilities => ({ export const nativeNamingAbiCapabilities = (capabilities: NativeOcctHistoryCapabilities): NativeNamingAbiCapabilities => ({
...NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, ...NATIVE_OCCT_NAMING_ABI_UNAVAILABLE,
...(capabilities.naming ?? {}), ...(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 = { export type NativeOcctHistoryRequest = {
protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION
requestId: string requestId: string
@@ -241,6 +265,7 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide
availability: 'available', 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'], 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', 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) return this.module.booleanHistoryFromStep(request.objectStep, request.toolStep!, request.operation)
}) })
if (signal.aborted) throw abortError() 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 })
} }
} }

View File

@@ -1,5 +1,6 @@
import type { NativeMultiTransformHistoryStep, NativeTopologyHistoryInput, NativeTopologyHistoryRecord, ShapeHandle, SubshapeRef } from './types' import type { NativeMultiTransformHistoryStep, NativeTopologyHistoryInput, NativeTopologyHistoryRecord, ShapeHandle, SubshapeRef } from './types'
import type { NativeStageNamingEvidence } from './nativeNamingEvidence' import type { NativeStageNamingEvidence } from './nativeNamingEvidence'
import type { NativeFreeCadNamingAbiModule } from './nativeNamingAbi'
export type NativeOcctMultiTransformStep = NativeMultiTransformHistoryStep 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 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 occtVersion(): string
booleanHistoryFromStep(objectStep: string, toolStep: string, operation: NativeOcctHistoryOperation): NativeOcctHistoryResponse 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 rotateHistoryFromStep?(shapeStep: string, axisOriginX: number, axisOriginY: number, axisOriginZ: number, axisDirectionX: number, axisDirectionY: number, axisDirectionZ: number, angleDegrees: number): NativeOcctHistoryResponse

View File

@@ -1,7 +1,8 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import type { NativeOcctHistoryStepProvider } from './nativeHistoryProvider' 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 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 } 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<string>() const cancelled = new Set<string>()
const send = (message: WorkerResponse) => scope.postMessage(message) 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) => { const captureMultiTransform = (module: NativeOcctHistoryStepProvider, request: NativeOcctHistoryRequest) => {
if (request.transforms && request.transforms.length >= 2) return module.multiTransformHistoryFromStep?.(request.objectStep, request.transforms) 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() provider = await imported.default()
send({ send({
type: 'ready', type: 'ready',
capabilities: { capabilities: capabilitiesFor(provider),
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,
},
}) })
} }
@@ -81,7 +84,25 @@ scope.onmessage = ({ data }: MessageEvent<WorkerRequest>) => {
: provider.booleanHistoryFromStep(request.objectStep, request.toolStep || '', request.operation) : 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 (!history) throw new Error('Native OCCT history provider does not expose the requested feature operation.')
if (cancelled.delete(data.request.requestId)) return 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) { } catch (error) {
send({ type: 'error', requestId: data.type === 'capture' ? data.request.requestId : undefined, error: error instanceof Error ? error.message : String(error) }) send({ type: 'error', requestId: data.type === 'capture' ? data.request.requestId : undefined, error: error instanceof Error ? error.message : String(error) })
} }

View File

@@ -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<string, string>
history: Pick<NativeOcctHistoryResponse, 'provider' | 'occtVersion' | 'records' | 'hasModified' | 'hasGenerated' | 'hasDeleted' | 'resultStep' | 'resultBrep'>
}
const encoder = new TextEncoder()
const lockedCommit = '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d'
const supportedOperations = new Set<NativeOcctHistoryOperation>(['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, 'schemaVersion'>): 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 }
}

View File

@@ -0,0 +1,288 @@
import type { DocumentObjectSnapshot, PropertyValue, VectorValue } from './types'
export type PartDesignParameterPartition = {
id: string
description: string
values: Record<string, PropertyValue>
}
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<string, PropertyValue>): 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<Record<string, PropertyValue>>, name: string) => typeof values[name] === 'number' ? values[name] as number : undefined
const string = (values: Readonly<Record<string, PropertyValue>>, name: string) => typeof values[name] === 'string' ? values[name] as string : undefined
const bool = (values: Readonly<Record<string, PropertyValue>>, name: string) => values[name] === true
const vector = (values: Readonly<Record<string, PropertyValue>>, 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<Record<string, PropertyValue>>, 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<Record<string, PropertyValue>>, 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<Record<string, PropertyValue>>, 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<DocumentObjectSnapshot, 'properties'>): Record<string, PropertyValue> => Object.fromEntries(object.properties.filter((property) => property.scope === 'data').map((property) => [property.name, property.value]))
export const validatePartDesignParameterSet = (
typeId: string,
values: Readonly<Record<string, PropertyValue>>,
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<DocumentObjectSnapshot, 'typeId' | 'properties'>): 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),
})

View File

@@ -12,7 +12,7 @@ export type FacadeRuntimeProfile = {
availability: 'configured-at-application-boundary' | 'not-configured' availability: 'configured-at-application-boundary' | 'not-configured'
} }
naming: { naming: {
nativeAbi: 'not-exposed' nativeAbi: 'freecad-private-v1-optional-worker' | 'not-exposed'
preservation: 'opaque-preserved' preservation: 'opaque-preserved'
} }
persistence: 'sqlite-opfs-with-memory-fallback' persistence: 'sqlite-opfs-with-memory-fallback'
@@ -31,7 +31,7 @@ export const createFacadeRuntimeProfile = (mode: FacadeRuntimeMode): FacadeRunti
availability: 'not-configured', availability: 'not-configured',
}, },
naming: { naming: {
nativeAbi: 'not-exposed', nativeAbi: mode === 'production' ? 'freecad-private-v1-optional-worker' : 'not-exposed',
preservation: 'opaque-preserved', preservation: 'opaque-preserved',
}, },
persistence: 'sqlite-opfs-with-memory-fallback', persistence: 'sqlite-opfs-with-memory-fallback',

View File

@@ -8,6 +8,9 @@ export type SketchGeometry =
| { id: string; type: 'circle'; center: SketchPoint; radius: number; construction?: boolean } | { 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: '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: '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 } | { 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' } 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: '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'; 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: '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' 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 (!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 === '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`) 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 (!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 (geometry.type === 'bspline') {
if (!Number.isSafeInteger(geometry.degree) || geometry.degree < 1) throw new RangeError(`${geometry.id} degree must be a positive integer.`) 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 => { export const cloneSketchGeometry = (geometry: SketchGeometry): SketchGeometry => {
validateSketchGeometry(geometry) validateSketchGeometry(geometry)
if (geometry.type === 'line') return { ...geometry, start: { ...geometry.start }, end: { ...geometry.end } } 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 } 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 } } 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 => { 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 === 'point' && point === 'position') return geometry.position
if (geometry.type === 'line' && (point === 'start' || point === 'end')) return point === 'start' ? geometry.start : geometry.end 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}'.` }) diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` })
return null return null
} }
@@ -583,7 +597,7 @@ const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], n
if (isBlocked(geometry.id, blocked)) return if (isBlocked(geometry.id, blocked)) return
if (geometry.type === 'point') { geometry.position = { ...next }; 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 (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[]) => { const validateConstraintValues = (constraint: SketchConstraint, diagnostics: SketchDiagnostic[]) => {
@@ -684,7 +698,7 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
const snapshot = cloneSketch(input) const snapshot = cloneSketch(input)
const diagnostics: SketchDiagnostic[] = [] const diagnostics: SketchDiagnostic[] = []
for (const geometry of snapshot.geometry) { 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}'.` }) 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])) 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 === 'line') return 4
if (geometry.type === 'circle') return 3 if (geometry.type === 'circle') return 3
if (geometry.type === 'arc' || geometry.type === 'ellipse') return 5 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) return geometry.controlPoints.length * 2 + (geometry.weights?.length ?? 0)
} }
const variableCount = snapshot.geometry.reduce((count, geometry) => count + variableCountForGeometry(geometry), 0) const variableCount = snapshot.geometry.reduce((count, geometry) => count + variableCountForGeometry(geometry), 0)

View File

@@ -17,9 +17,11 @@ import { runTopologyMutationReplay } from '../src/facade/topologyReplay'
import { captureNativeTopologyHistory, captureNativeTopologyHistoryStages, captureSignatureTopologyHistory, composeNativeTopologyHistoryLineage } from '../src/facade/topologyHistory' import { captureNativeTopologyHistory, captureNativeTopologyHistoryStages, captureSignatureTopologyHistory, composeNativeTopologyHistoryLineage } from '../src/facade/topologyHistory'
import { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '../src/facade/nativeHistoryProvider' import { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '../src/facade/nativeHistoryProvider'
import { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, nativeNamingAbiCapabilities, type NativeOcctHistoryProvider } from '../src/facade/nativeHistoryProtocol' 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 { NativeOcctHistoryWorkerProvider } from '../src/facade/nativeHistoryWorkerClient'
import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy' 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 { 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 { solvePlanegcsSubset, type PlanegcsWasmModule } from '../src/facade/planegcsAdapter'
import { PlanegcsWorkerProvider } from '../src/facade/planegcsWorkerClient' 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.deepEqual(state.selectedObjectIds, [])
assert.equal(facade.runtime.mode, 'production') assert.equal(facade.runtime.mode, 'production')
assert.equal(facade.runtime.geometry.compatibility, 'none') 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() 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 }], 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 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' }) 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') 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', () => { 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 } 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), { mappedNameRef: 'unavailable', stringHasher: 'unavailable', elementMap2: '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, 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<string, unknown> | 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<string, unknown>
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 () => { 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') 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 () => { test('native OCCT Worker provider preserves versioned request and response context', async () => {
const messageListeners = new Set<(event: MessageEvent) => void>() const messageListeners = new Set<(event: MessageEvent) => void>()
const errorListeners = new Set<(event: ErrorEvent) => 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/) 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', () => { 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 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'] } 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'] }