feat: complete supported Sketcher and PartDesign ABI contract
This commit is contained in:
@@ -97,7 +97,7 @@
|
||||
"status": "explicit-runtime-contract",
|
||||
"mappedNameRef": "optional provider evidence; final-shape-only stages are marked and cannot mint FreeCAD tokens",
|
||||
"stringHasher": "optional opaque native table; validated and preserved losslessly",
|
||||
"exactBlockers": ["browser FreeCAD private token callback ABI", "browser builder naming evidence transport", "non-unique isomorphic source"]
|
||||
"exactBlockers": ["shipped Worker lacks the FreeCAD-linked private naming ABI implementation", "browser builder naming evidence transport", "non-unique isomorphic source"]
|
||||
}
|
||||
},
|
||||
"browserWorker": {
|
||||
@@ -124,13 +124,13 @@
|
||||
"verification": { "level": "experimental", "provider": "FreeCADCmd 1.1.1 native headless Part oracle", "operations": ["locked-source-commit", "reproducible-native-build", "declarative-golden-contract", "part-geometry-replay", "numeric-tolerance-comparison", "execution-plan-validation", "source-inventory-validation", "type-property-inventory-validation"] }
|
||||
},
|
||||
"systemExactEvaluation": {
|
||||
"evaluatedAt": "2026-08-10",
|
||||
"evaluatedAt": "2026-08-11",
|
||||
"exact": false,
|
||||
"featureExactCount": 0,
|
||||
"plan": "config/freecad-web-exact-parity-plan.json",
|
||||
"promotionTask": "EX-REL-01",
|
||||
"taskStatus": { "completed": 2, "inProgress": 36, "pending": 14, "blocked": 0 },
|
||||
"blockers": ["browser FreeCAD private token callback ABI", "browser builder naming evidence transport", "non-unique isomorphic source"]
|
||||
"taskStatus": { "completed": 2, "inProgress": 37, "pending": 13, "blocked": 0 },
|
||||
"blockers": ["shipped Worker lacks the FreeCAD-linked private naming ABI implementation", "browser builder naming evidence transport", "non-unique isomorphic source"]
|
||||
},
|
||||
"rules": [
|
||||
"UI status does not imply geometry or file compatibility.",
|
||||
|
||||
99
config/freecad-sketcher-partdesign-abi-contract.json
Normal file
99
config/freecad-sketcher-partdesign-abi-contract.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
{ "id": "EX-KER-01", "title": "Match OCCT construction, validation, tolerances and failure diagnostics", "priority": "P0", "status": "in_progress", "dependencies": ["EX-ORA-02"], "deliverables": ["Per-operation native differential", "Tolerance and placement contract", "Failure-code map"], "acceptance": ["Shape validity, topology, mass properties and diagnostics match locked FreeCAD fixtures", "Worker ownership returns to zero after every case"], "evidence": ["check:browser-occt", "check:chrome-geometry-features"], "exactBlockedBy": ["Not all FreeCAD builder flags and failure branches are represented"] },
|
||||
{ "id": "EX-TSN-00", "title": "Fail closed when native naming or builder evidence is absent", "priority": "P0", "status": "completed", "dependencies": [], "deliverables": ["Runtime naming evidence contract", "final-shape-only and ambiguous persistence", "ElementMap2 writer evidence guard"], "acceptance": ["Final geometry cannot mint a private FreeCAD token", "Unproven stage relations and isomorphic sources cannot become stable"], "evidence": ["check:freecad-native-naming-evidence", "check:freecad-exact-history-elementmap-gate"], "exactBlockedBy": [] },
|
||||
{ "id": "EX-TSN-01", "title": "Capture native stage Shape and Generated/Modified/Deleted for every builder", "priority": "P0", "status": "completed", "dependencies": ["EX-KER-01", "EX-TSN-00"], "deliverables": ["All builder-stage captures", "Multi-input and one-to-many lineage", "Local feature recompute history"], "acceptance": ["Every feature result relation has a producing native stage", "No final-result index is applied to an intermediate Shape"], "evidence": ["check:freecad-composite-history-elementmap", "check:freecad-tsn-stage-evidence"], "exactBlockedBy": [] },
|
||||
{ "id": "EX-TSN-02", "title": "Expose native MappedNameRef and StringHasher decisions for every feature", "priority": "P0", "status": "pending", "dependencies": ["EX-TSN-01"], "deliverables": ["Native naming callback ABI", "Per-stage StringHasher evidence", "Lossless ElementMap2 token generation"], "acceptance": ["All new tokens originate in native evidence", "FreeCAD round-trip names remain byte and semantic stable"], "evidence": ["check:freecad-native-naming-evidence"], "exactBlockedBy": ["FreeCAD private naming callbacks are not exposed for every builder"] },
|
||||
{ "id": "EX-TSN-02", "title": "Expose native MappedNameRef and StringHasher decisions for every feature", "priority": "P0", "status": "in_progress", "dependencies": ["EX-TSN-01"], "deliverables": ["Native naming callback ABI", "Per-stage StringHasher evidence", "Lossless ElementMap2 token generation"], "acceptance": ["All new tokens originate in native evidence", "FreeCAD round-trip names remain byte and semantic stable"], "evidence": ["check:freecad-native-naming-evidence", "check:freecad-sketcher-partdesign-abi"], "exactBlockedBy": ["The shipped Worker does not link the FreeCAD private naming implementation for every builder"] },
|
||||
{ "id": "EX-TSN-03", "title": "Resolve isomorphic topology only from unique native provenance", "priority": "P0", "status": "in_progress", "dependencies": ["EX-TSN-01", "EX-TSN-02"], "deliverables": ["Symmetric Boolean corpus", "Candidate provenance graph", "Ambiguity repair lifecycle"], "acceptance": ["Unique native sources resolve deterministically", "Non-unique sources remain ambiguous across save, undo and recompute"], "evidence": ["test:topology-replay", "check:freecad-exact-history-elementmap-gate"], "exactBlockedBy": ["Symmetric cases without unique native provenance remain intentionally ambiguous"] },
|
||||
{ "id": "EX-TSN-04", "title": "Close topology naming across all feature edits and FCStd round-trips", "priority": "P0", "status": "pending", "dependencies": ["EX-TSN-02", "EX-TSN-03"], "deliverables": ["Cross-feature mutation matrix", "ElementMap2 history migration", "Long-chain LinkSub stability report"], "acceptance": ["Wrong bindings, unexplained relations and name drift are zero for the exhaustive corpus"], "evidence": ["check:freecad-fcstd-roundtrip", "check:freecad-exact-history-elementmap-gate"], "exactBlockedBy": ["Exhaustive cross-feature naming corpus is incomplete"] }
|
||||
]
|
||||
@@ -52,10 +52,10 @@
|
||||
"id": "EX04",
|
||||
"title": "Sketcher, Part and PartDesign feature exactness",
|
||||
"tasks": [
|
||||
{ "id": "EX-SK-01", "title": "Cover every Sketcher geometry, constraint overload and solver classification", "priority": "P0", "status": "in_progress", "dependencies": ["EX-KER-01", "EX-DOC-02"], "deliverables": ["Complete geometry and constraint matrix", "Planegcs result parity", "Reference, redundant and conflict oracle"], "acceptance": ["Geometry, DOF, diagnostics and solver results match every fixture"], "evidence": ["check:freecad-sketcher-constraints", "check:chrome-sketcher-diagnostics"], "exactBlockedBy": ["Arbitrary overload combinations and all failure messages are not exhaustive"] },
|
||||
{ "id": "EX-SK-01", "title": "Cover every Sketcher geometry, constraint overload and solver classification", "priority": "P0", "status": "in_progress", "dependencies": ["EX-KER-01", "EX-DOC-02"], "deliverables": ["Complete geometry and constraint matrix", "Planegcs result parity", "Reference, redundant and conflict oracle"], "acceptance": ["Geometry, DOF, diagnostics and solver results match every fixture"], "evidence": ["check:freecad-sketcher-constraints", "check:freecad-sketcher-partdesign-abi", "check:chrome-sketcher-diagnostics"], "exactBlockedBy": ["Arbitrary overload combinations and all failure messages are not exhaustive"] },
|
||||
{ "id": "EX-SK-02", "title": "Match Sketcher editing tools, autoconstraints, virtual space and UI lifecycle", "priority": "P0", "status": "in_progress", "dependencies": ["EX-SK-01", "EX-UI-03"], "deliverables": ["Pointer and keyboard editor oracle", "All editing tools", "Task and focus lifecycle"], "acceptance": ["Every tool supports success, failure, cancel, undo and redo with matching selection"], "evidence": ["check:freecad-sketcher-editor", "check:chrome-sketcher-bspline"], "exactBlockedBy": ["The full GUI tool and focus matrix is incomplete"] },
|
||||
{ "id": "EX-PART-01", "title": "Complete all Part primitives, builders, booleans, healing and inspection", "priority": "P0", "status": "in_progress", "dependencies": ["EX-KER-01", "EX-TSN-04"], "deliverables": ["All Part commands and parameters", "Healing and tolerance tools", "Native history for local operations"], "acceptance": ["Success and failure outputs match native Shape, history and diagnostics"], "evidence": ["check:chrome-part-primitives", "check:freecad-golden-fixtures"], "exactBlockedBy": ["Current golden corpus does not enumerate every Part command and option"] },
|
||||
{ "id": "EX-PD-01", "title": "Complete all PartDesign features, parameters and additive/subtractive combinations", "priority": "P0", "status": "in_progress", "dependencies": ["EX-PART-01", "EX-TSN-04"], "deliverables": ["Complete PartDesign feature matrix", "Body Tip and feature-list semantics", "Per-feature native history and naming"], "acceptance": ["Every feature edit, failure recovery and save-reopen matches FreeCAD"], "evidence": ["check:partdesign-closure", "check:chrome-partdesign-lifecycle"], "exactBlockedBy": ["Feature coverage is broad but private naming and all parameter combinations are not exhaustive"] },
|
||||
{ "id": "EX-PD-01", "title": "Complete all PartDesign features, parameters and additive/subtractive combinations", "priority": "P0", "status": "in_progress", "dependencies": ["EX-PART-01", "EX-TSN-04"], "deliverables": ["Complete PartDesign feature matrix", "Body Tip and feature-list semantics", "Per-feature native history and naming"], "acceptance": ["Every feature edit, failure recovery and save-reopen matches FreeCAD"], "evidence": ["check:partdesign-closure", "check:freecad-sketcher-partdesign-abi", "check:chrome-partdesign-lifecycle"], "exactBlockedBy": ["The parameter contract covers the supported Facade subset, not every registered PartDesign TypeId or cross-product"] },
|
||||
{ "id": "EX-PD-02", "title": "Complete attachment, datum, ShapeBinder, SubShapeBinder and body workflows", "priority": "P0", "status": "pending", "dependencies": ["EX-PD-01", "EX-DOC-04"], "deliverables": ["All map modes and support rules", "Datum and binder lifecycle", "Multi-body and cross-document corpus"], "acceptance": ["Support migration, visibility, Tip and references remain exact through mutation"], "evidence": ["check:chrome-partdesign-lifecycle", "check:freecad-fcstd-roundtrip"], "exactBlockedBy": ["Complete datum/binder and multi-body oracle is absent"] }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -22,20 +22,20 @@
|
||||
"bytes": 556597,
|
||||
"sha256": "1679307fb0f01e9d7b9f37a081458bf44583e4d1899edf9224b49a1435ce7105"
|
||||
},
|
||||
{
|
||||
"path": "assets/index-BcH6J3tB.js",
|
||||
"bytes": 1103011,
|
||||
"sha256": "870d33fae0ceabaf31a8100e65ad7f1afb3f4f869bfc225bb8c722d7771ca917"
|
||||
},
|
||||
{
|
||||
"path": "assets/index-BnRqhr3m.css",
|
||||
"bytes": 65976,
|
||||
"sha256": "b8eacf86224d2705e3c27367b3a0b166f4d662a5b8142d34a8abcdeef58753ec"
|
||||
},
|
||||
{
|
||||
"path": "assets/nativeHistoryWorkerEntry-D9i46nqJ.js",
|
||||
"bytes": 4664,
|
||||
"sha256": "488e45728dd4ccfd65acb82c61266332171129a52e916ecbb7cc2a8ab61d4ed6"
|
||||
"path": "assets/index-ChdGmY7w.js",
|
||||
"bytes": 1130165,
|
||||
"sha256": "c508b701c47d2dea412c96f3a529f99b42bd7b137e647d958b993c3268dfc3e6"
|
||||
},
|
||||
{
|
||||
"path": "assets/nativeHistoryWorkerEntry-gNsJxM2G.js",
|
||||
"bytes": 20437,
|
||||
"sha256": "39c26691ba3275ce5fbb3a43239d5511430bc0222259f0c76e3aa9314f2e87cf"
|
||||
},
|
||||
{
|
||||
"path": "assets/persistenceWorker-CyD5EUVX.js",
|
||||
@@ -43,9 +43,9 @@
|
||||
"sha256": "65a24ca0125444e3047f409d3b1c3667321e392a0ca9ddf64df6b728a1544c66"
|
||||
},
|
||||
{
|
||||
"path": "assets/planegcsWorkerEntry-BEpPJPEo.js",
|
||||
"bytes": 26112,
|
||||
"sha256": "2b5b0a9604aa8181f38b803753ad42e33e6c426a9fd8c1df013ae8762c6bb5c0"
|
||||
"path": "assets/planegcsWorkerEntry-BeAnOOBp.js",
|
||||
"bytes": 26910,
|
||||
"sha256": "c2253b714352c02e543fa97dff1c1e7c64ae3983469d4e5a6b9bf75b78ba3483"
|
||||
},
|
||||
{
|
||||
"path": "assets/sqlite3-BVKGSWc-.wasm",
|
||||
@@ -330,7 +330,7 @@
|
||||
{
|
||||
"path": "index.html",
|
||||
"bytes": 678,
|
||||
"sha256": "07fab5a0e83597c5ca0a0cd2d57625f0134b56c02f2349a35be4e6de23540d33"
|
||||
"sha256": "ce1c0c151dfb290cff01268efaec2efd0cdc354548ffccb5cb1030b02249870f"
|
||||
},
|
||||
{
|
||||
"path": "manifest.webmanifest",
|
||||
@@ -380,7 +380,7 @@
|
||||
{
|
||||
"path": "sw.js",
|
||||
"bytes": 1390,
|
||||
"sha256": "01593ec533053a9ccdf341ef276ee5c0e1752e6dc30951acdc042c33d60f206c"
|
||||
"sha256": "a8d35592358f8580b3be765a36897969c63ebfb7c777a71296481e0d8118fad7"
|
||||
},
|
||||
{
|
||||
"path": "vendor/camotics/camotics-sweep.wasm",
|
||||
@@ -402,7 +402,7 @@
|
||||
"status": "signed",
|
||||
"algorithm": "Ed25519",
|
||||
"keyId": "bitbybit-local-release-2026",
|
||||
"payloadSha256": "f1fe903dc9879b0f02d1a4becbd5daf7fb9d7b96ef304f2cc04fc5058d80256e",
|
||||
"signature": "RY1mpA6T/zWYx1/Z/9Z7A2kpkBbbCbP8TQIIykaFbY2E8qzmlAw0qotNYq3yOP5OfbJqas+BjAXWJeEY7WA4DQ=="
|
||||
"payloadSha256": "be48e099205c1a86339eb4560e62ed88e12ffd0e2e45e036f3a723abd3bb078a",
|
||||
"signature": "AAyAo8hzt1I48LaHUArN7lEg11zWrl3EkNKF6zr92x2SZHFhXq/5m8KZ0hqJxNU4INM6bC/laqanCJ9GhRbkCQ=="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1772,6 +1772,16 @@ FCStd 双向往返新增真实 `Part::Cut` 穿孔文档:`20 x 20 x 10` Box 由
|
||||
|
||||
TSN 门不再只读取 Pad/Pocket 两份报告。它现在汇总 16 份 Chrome/WASM 原生 history 报告,逐份校验 provider、operation registry、Worker/WASM、关系计数、结果 Solid、stage input/result/topology、专用 marker 和释放后 `0/0`;19 类 operation 的证据为 `19/19`,缺失为 `0`。Pocket、Revolution、Groove、Hole、MultiTransform 的 17 个显式 stage 继续逐级校验,其他专用 builder 由操作报告和 marker 绑定。`EX-TSN-01` 因此更新为 `completed`,exact 计划计数为 `2 completed / 36 in_progress / 14 pending / 0 blocked`。
|
||||
|
||||
命名 oracle 同时修正了旧的误分类。FreeCAD 运行时 219 个阶段中,74 个派生/操作阶段要求私有 MappedName,现为 `required=74 / complete=74`;145 个基础或支持阶段由原生 API 明确返回 `IndexedName-only`,不再被错误统计为 145 个缺失私有 token。42 个 Part/PartDesign builder 阶段均具有直接 `getElementHistory/getElementMappedName/getElementIndexedName` 证据,结果为 `42/42`、missing `0`。这只闭合锁定 oracle 的证据,不代表浏览器 OCCT 能为任意新模型执行 FreeCAD 私有命名算法:浏览器仍没有 `MappedNameRef/StringHasher` callback ABI,原生 token 仍限于 fixture/oracle,非唯一同构来源仍必须保持 ambiguous。
|
||||
命名 oracle 同时修正了旧的误分类。FreeCAD 运行时 219 个阶段中,74 个派生/操作阶段要求私有 MappedName,现为 `required=74 / complete=74`;145 个基础或支持阶段由原生 API 明确返回 `IndexedName-only`,不再被错误统计为 145 个缺失私有 token。42 个 Part/PartDesign builder 阶段均具有直接 `getElementHistory/getElementMappedName/getElementIndexedName` 证据,结果为 `42/42`、missing `0`。这只闭合锁定 oracle 的证据,不代表浏览器 OCCT 能为任意新模型执行 FreeCAD 私有命名算法:当时浏览器尚无 `MappedNameRef/StringHasher` callback ABI,原生 token 仍限于 fixture/oracle,非唯一同构来源仍必须保持 ambiguous。
|
||||
|
||||
真实 CI oracle lane 已调整为先重跑核心参数、全部 Part/PartDesign、复合命名和 FCStd oracle,再生成参数报告并执行检查;Chrome lane在所有 `test:chrome-*` 重跑后执行 19-operation TSN 聚合门,避免读取旧报告。当前 `systemExact=false`、`EX-TSN-02=pending`、`EX-TSN-04=pending`、`EX-REL-01=pending`、exact modules `0/34`;剩余工作不得用锁定 fixture 的 token 代替浏览器实时私有命名回调。
|
||||
|
||||
## 229. 2026-08-11 Sketcher/PartDesign 参数合同与 FreeCAD 私有命名 ABI v1
|
||||
|
||||
Sketcher 数据模型和 FCStd codec 已覆盖 FreeCAD 1.1.1 的完整原生几何类型表:在 Point/Line/Circle/Arc/Ellipse/B-spline 基础上新增 `GeomArcOfEllipse`、`GeomArcOfHyperbola` 和 `GeomArcOfParabola`,并补齐 InternalAlignment 原生编号 `5/6/7/8/11` 的双曲线主轴/副轴/焦点与抛物线焦点/焦轴。9 类 geometry、19 类 constraint、10 类 InternalAlignment 语义由公开常量锁定;新圆锥曲线和 helper geometry 已通过 FCStd 写出、解析和 Web ID 恢复测试。basic TypeScript solver 对尚未求解的圆锥曲线明确返回 unsupported,不伪装成已求解。
|
||||
|
||||
新增 `partDesignParameters.ts`,将当前 Facade 支持的 Datum/ShapeBinder、Pad/Pocket、Revolution/Groove、Loft/Pipe、dress-up、transform/pattern 和 Hole 共 21 个 family 建成 202 个属性槽、72 个语义分区的可执行合同。对象创建和属性编辑现在统一检查两侧长度/角度、Midplane 冲突、Up-to-face 引用、自定义向量、Loft section、Pipe spine、Chamfer 模式、pattern occurrence/方向和 Hole cut/thread 组合。经锁定桌面对象探针对账,FreeCAD 实际注册的同名字段进入 FCStd native property 白名单;`AxisLink`、显式方向向量、`ThreadPitch` 等 Web 兼容字段仍按动态属性保存,不冒充原生属性。
|
||||
|
||||
新增 `FreeCADPrivateNamingABI v1`:三项 Embind 回调分别报告 ABI version、锁定 FreeCAD 1.1.1/commit/operation 能力,并按 JSON 请求返回命名证据。Direct provider 与 Worker 都传输输入 STEP、最终 STEP/BRep、stage DAG 和 OCCT history;回包必须包含原生/歧义状态、MappedNameRef、StringHasher、ElementMap2,并通过 stage/result 上下文、16/32 MiB 上限、StringID、ElementMap2 和两者引用闭包检查。缺回调、版本/提交不符、operation 未声明或证据不合法均不生成 token,继续保留 `final-shape-only`。
|
||||
|
||||
`config/freecad-sketcher-partdesign-abi-contract.json` 与 `check:freecad-sketcher-partdesign-abi` 已进入默认 Sketcher 门禁,Facade 为 `190/190`,独立合同门、Facade boundary、锁定 FreeCAD 原生 FCStd 门和全量 `verify` 均通过。这里完成的是支持范围内的参数合同、FCStd 表面和私有 ABI/传输/验证层;当前随附的 `native/occt-history` 仍未链接 FreeCAD 私有 C++,所以浏览器实时命名实现仍为 `not-linked`。`EX-TSN-02` 由 `pending` 转为 `in_progress`,机器计划为 `2 completed / 37 in_progress / 13 pending / 0 blocked`;`systemExact=false` 和 promotion fail-closed 状态不变。
|
||||
|
||||
@@ -389,6 +389,10 @@ PartDesign Mirrored 已形成 experimental whole-shape 垂直切片:Facade 验
|
||||
|
||||
运行时命名证据必须来自 native provider 的 `MappedNameRef`/`StringHasher` 或逐阶段 builder capture。`NativeStageNamingEvidence` 对缺失证据写出 `final-shape-only`,对只有原始表而没有可重建回调的 FCStd 资源写出 `opaque-preserved`;两者都不能仅凭最终 Shape 生成新 FreeCAD token。跨阶段 ElementMap2 映射保存 `sourceStageId`、来源集合和 `ambiguous` 候选,校验失败直接阻断写回。
|
||||
|
||||
`FreeCADPrivateNamingABI v1` 已定义为版本化 Embind/JSON 合同,并接入 direct provider 与真实 Worker:请求携带 operation、输入 STEP、最终 STEP/BRep、阶段 DAG 和原生 history,响应必须同时提供 `MappedNameRef`、StringHasher 与 ElementMap2,且逐项通过 stage/result 绑定、大小上限、schema、StringID 和引用闭包校验。生产运行时声明 `freecad-private-v1-optional-worker`,mock 明确为 `not-exposed`;未导出三项回调、版本/锁定提交不符、证据不闭合或 operation 未声明时均 fail-closed 到 `final-shape-only`,禁止合成 token。当前仓库随附的 `native/occt-history` 仍是 OCCT-only artifact,没有链接 FreeCAD 私有实现,因此这里只完成 ABI 与传输边界,不把浏览器实时私有命名提升为 exact。
|
||||
|
||||
Sketcher/PartDesign 的 Web 支持范围新增可执行参数合同:Sketcher 覆盖 FreeCAD 1.1.1 FCStd 表面的 9 类 geometry、19 类 constraint 和 10 类 InternalAlignment 语义;PartDesign 覆盖 Facade 已支持的 21 个 family、202 个属性槽和 72 个语义分区,并在对象创建及属性编辑时执行跨字段校验。`check:freecad-sketcher-partdesign-abi` 会从实现重算这些计数并拒绝陈旧报告;该“完整”限定于 supported Web Facade,不包含尚未实现的 FreeCAD PartDesign TypeId,也不表示 basic TypeScript solver 已求解全部圆锥曲线约束。
|
||||
|
||||
逐 feature 等级目前由 `config/compatibility-matrix.json` 的 `facadeCapabilities.geometry.featureLevels` 管理:Pad/Pocket/Revolution/Groove/Boolean 为有 native history 子集的 `compatible`,Fillet/Chamfer/MultiTransform 保持 `experimental`。`systemExactEvaluation.exact` 必须在所有 feature 的阶段 Shape/history、私有 token 证据和同构来源都闭合后才允许改为 true;当前值固定为 false,阻断项为私有 FreeCAD token 算法、没有原生 builder stage、以及无唯一同构来源。
|
||||
|
||||
### 21. 全功能 exact 计划(2026-08-09)
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
"build:freecad-native": "node scripts/build-freecad-native.mjs",
|
||||
"check:freecad-desktop-oracle": "node scripts/check-freecad-desktop-oracle.mjs",
|
||||
"probe:freecad-sketcher-constraints": "node scripts/run-freecad-sketcher-constraint-oracle.mjs",
|
||||
"check:freecad-sketcher-constraints": "node scripts/check-freecad-sketcher-constraint-oracle.mjs && node scripts/check-freecad-partdesign-profile-oracle.mjs && node scripts/check-freecad-partdesign-base-oracle.mjs && node scripts/check-freecad-partdesign-loft-oracle.mjs && node scripts/check-freecad-partdesign-dressup-oracle.mjs && node scripts/check-freecad-partdesign-transform-oracle.mjs && node scripts/check-freecad-partdesign-failure-oracle.mjs && node scripts/check-freecad-partdesign-revolution-groove-oracle.mjs && node scripts/check-freecad-part-builders-oracle.mjs && node scripts/check-freecad-sketcher-editor-oracle.mjs",
|
||||
"check:freecad-sketcher-constraints": "node scripts/check-freecad-sketcher-constraint-oracle.mjs && node scripts/check-freecad-partdesign-profile-oracle.mjs && node scripts/check-freecad-partdesign-base-oracle.mjs && node scripts/check-freecad-partdesign-loft-oracle.mjs && node scripts/check-freecad-partdesign-dressup-oracle.mjs && node scripts/check-freecad-partdesign-transform-oracle.mjs && node scripts/check-freecad-partdesign-failure-oracle.mjs && node scripts/check-freecad-partdesign-revolution-groove-oracle.mjs && node scripts/check-freecad-part-builders-oracle.mjs && node scripts/check-freecad-sketcher-editor-oracle.mjs && npm run check:freecad-sketcher-partdesign-abi",
|
||||
"check:freecad-sketcher-partdesign-abi": "tsx scripts/check-freecad-sketcher-partdesign-abi-contract.mjs",
|
||||
"probe:freecad-partdesign-profiles": "node scripts/run-freecad-partdesign-profile-oracle.mjs",
|
||||
"check:freecad-partdesign-profiles": "node scripts/check-freecad-partdesign-profile-oracle.mjs",
|
||||
"probe:freecad-partdesign-base": "node scripts/run-freecad-partdesign-base-oracle.mjs",
|
||||
|
||||
@@ -36,7 +36,7 @@ const application = await readFile(new URL('App.tsx', sourceRoot), 'utf8')
|
||||
const runtimeProfile = await readFile(new URL('facade/runtimeProfile.ts', sourceRoot), 'utf8')
|
||||
if (/from\s+["']\.\/mockFacade["']/.test(productionFacade)) violations.push('facade/productionFacade.ts: production entry imports mockFacade directly')
|
||||
if (/createMockFacade|facade\/mockFacade/.test(application)) violations.push('App.tsx: production application references the mock Facade')
|
||||
for (const declaration of ['bitbybit-occt', 'optional-worker', 'not-exposed', 'sqlite-opfs-with-memory-fallback', 'three-webgl2']) {
|
||||
for (const declaration of ['bitbybit-occt', 'optional-worker', 'freecad-private-v1-optional-worker', 'not-exposed', 'sqlite-opfs-with-memory-fallback', 'three-webgl2']) {
|
||||
if (!runtimeProfile.includes(declaration)) violations.push(`facade/runtimeProfile.ts: missing explicit runtime boundary '${declaration}'`)
|
||||
}
|
||||
|
||||
|
||||
50
scripts/check-freecad-sketcher-partdesign-abi-contract.mjs
Normal file
50
scripts/check-freecad-sketcher-partdesign-abi-contract.mjs
Normal 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}`)
|
||||
@@ -334,6 +334,26 @@ const nativeDataPropertyNames = new Map<string, ReadonlySet<string>>([
|
||||
['Part::Revolution', new Set(['Source', 'Base', 'Axis', 'AxisLink', 'Angle', 'Symmetric', 'Solid', 'FaceMakerClass'])],
|
||||
['Part::Loft', new Set(['Sections', 'Solid', 'Ruled', 'Closed', 'MaxDegree', 'Linearize'])],
|
||||
['Part::Sweep', new Set(['Sections', 'Spine', 'Solid', 'Frenet', 'Transition', 'Linearize'])],
|
||||
['PartDesign::Plane', new Set(['MapMode', 'AttachmentOffset'])],
|
||||
['PartDesign::Line', new Set(['MapMode', 'AttachmentOffset'])],
|
||||
['PartDesign::Point', new Set(['MapMode', 'AttachmentOffset'])],
|
||||
['PartDesign::ShapeBinder', new Set(['Support', 'TraceSupport'])],
|
||||
['PartDesign::Pad', new Set(['Profile', 'Length', 'Length2', 'Type', 'Type2', 'SideType', 'UpToFace', 'UpToFace2', 'TaperAngle', 'TaperAngle2', 'Reversed', 'Midplane', 'ReferenceAxis', 'AlongSketchNormal', 'UseCustomVector', 'Direction', 'Offset', 'Offset2'])],
|
||||
['PartDesign::Pocket', new Set(['Profile', 'Length', 'Length2', 'Type', 'Type2', 'SideType', 'UpToFace', 'UpToFace2', 'TaperAngle', 'TaperAngle2', 'Reversed', 'Midplane', 'ReferenceAxis', 'AlongSketchNormal', 'UseCustomVector', 'Direction', 'Offset', 'Offset2'])],
|
||||
['PartDesign::Revolution', new Set(['Profile', 'Angle', 'Angle2', 'Type', 'UpToFace', 'ReferenceAxis', 'Axis', 'Midplane', 'Reversed'])],
|
||||
['PartDesign::Groove', new Set(['Base', 'Profile', 'Angle', 'Angle2', 'Type', 'UpToFace', 'ReferenceAxis', 'Axis', 'Midplane', 'Reversed'])],
|
||||
['PartDesign::AdditiveLoft', new Set(['Profile', 'Sections', 'Ruled', 'Closed'])],
|
||||
['PartDesign::SubtractiveLoft', new Set(['Profile', 'Sections', 'Ruled', 'Closed'])],
|
||||
['PartDesign::AdditivePipe', new Set(['Profile', 'Spine', 'Transition', 'Mode', 'Transformation'])],
|
||||
['PartDesign::SubtractivePipe', new Set(['Profile', 'Spine', 'Transition', 'Mode', 'Transformation'])],
|
||||
['PartDesign::Fillet', new Set(['Base', 'Radius', 'UseAllEdges'])],
|
||||
['PartDesign::Chamfer', new Set(['Base', 'Size', 'Size2', 'Angle', 'ChamferType', 'FlipDirection', 'UseAllEdges'])],
|
||||
['PartDesign::Draft', new Set(['Base', 'Angle', 'Reversed'])],
|
||||
['PartDesign::Thickness', new Set(['Base', 'Value', 'Join', 'Mode', 'Reversed'])],
|
||||
['PartDesign::Mirrored', new Set(['Originals', 'TransformMode'])],
|
||||
['PartDesign::MultiTransform', new Set(['Originals', 'TransformMode', 'Transformations'])],
|
||||
['PartDesign::LinearPattern', new Set(['Originals', 'TransformMode', 'Occurrences', 'Length', 'Offset', 'Direction', 'Mode', 'Reversed', 'Spacings', 'SpacingPattern', 'Direction2', 'Mode2', 'Length2', 'Offset2', 'Occurrences2', 'Reversed2', 'Spacings2', 'SpacingPattern2'])],
|
||||
['PartDesign::PolarPattern', new Set(['Originals', 'TransformMode', 'Occurrences', 'Angle', 'Axis', 'Mode', 'Offset', 'Spacings', 'SpacingPattern', 'Reversed'])],
|
||||
['PartDesign::Hole', new Set(['Threaded', 'ModelThread', 'ThreadType', 'ThreadSize', 'ThreadClass', 'ThreadFit', 'Diameter', 'ThreadDiameter', 'ThreadDirection', 'HoleCutType', 'HoleCutCustomValues', 'HoleCutDiameter', 'HoleCutDepth', 'HoleCutCountersinkAngle', 'DepthType', 'Depth', 'DrillPoint', 'DrillPointAngle', 'DrillForDepth', 'Tapered', 'TaperedAngle', 'ThreadDepthType', 'ThreadDepth', 'UseCustomThreadClearance', 'CustomThreadClearance', 'BaseProfileType'])],
|
||||
['Part::Fuse', new Set(['Base', 'Tool', 'Refine'])],
|
||||
['Part::Cut', new Set(['Base', 'Tool', 'Refine'])],
|
||||
@@ -384,6 +404,9 @@ const sketchGeometryType = {
|
||||
circle: 'Part::GeomCircle',
|
||||
arc: 'Part::GeomArcOfCircle',
|
||||
ellipse: 'Part::GeomEllipse',
|
||||
arcEllipse: 'Part::GeomArcOfEllipse',
|
||||
arcHyperbola: 'Part::GeomArcOfHyperbola',
|
||||
arcParabola: 'Part::GeomArcOfParabola',
|
||||
bspline: 'Part::GeomBSplineCurve',
|
||||
} as const
|
||||
|
||||
@@ -393,6 +416,9 @@ const sketchGeometryPayloadXml = (geometry: SketchGeometry) => {
|
||||
if (geometry.type === 'circle') return `<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 === '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 knotCount = geometry.controlPoints.length + geometry.degree + 1
|
||||
const knots = geometry.knots ?? Array.from({ length: knotCount }, (_, index) => {
|
||||
@@ -411,7 +437,7 @@ const sketchGeometryPayloadXml = (geometry: SketchGeometry) => {
|
||||
return `<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 = {
|
||||
key: string
|
||||
@@ -533,8 +559,8 @@ const sketchInternalGeometryPlan = (sketch: SketchSnapshot): SketchInternalGeome
|
||||
firstPosition: 1,
|
||||
geometry: { id: `Internal${helpers.size}`, type: 'point', position: bsplinePointAtParameter(target, uniqueKnots[constraint.internalGeometryIndex]), construction: true },
|
||||
})
|
||||
} else {
|
||||
if (target.type !== 'ellipse') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires ellipse geometry '${constraint.geometryId}'.`)
|
||||
} else if (constraint.alignmentType.startsWith('ellipse-')) {
|
||||
if (target.type !== 'ellipse' && target.type !== 'arcEllipse') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires ellipse geometry '${constraint.geometryId}'.`)
|
||||
const focusIndex = constraint.alignmentType === 'ellipse-focus' ? constraint.internalGeometryIndex : 0
|
||||
if ((constraint.alignmentType === 'ellipse-focus' && focusIndex !== 0 && focusIndex !== 1) || (constraint.alignmentType !== 'ellipse-focus' && constraint.internalGeometryIndex !== 0)) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' has an invalid ellipse internal index.`)
|
||||
const alignmentType = constraint.alignmentType === 'ellipse-major' ? 1 : constraint.alignmentType === 'ellipse-minor' ? 2 : focusIndex === 0 ? 3 : 4
|
||||
@@ -548,6 +574,31 @@ const sketchInternalGeometryPlan = (sketch: SketchSnapshot): SketchInternalGeome
|
||||
? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.minorRadius * minorDirection.x, y: target.center.y + target.minorRadius * minorDirection.y }, end: { x: target.center.x - target.minorRadius * minorDirection.x, y: target.center.y - target.minorRadius * minorDirection.y }, construction: true }
|
||||
: { id: `Internal${helpers.size}`, type: 'point', position: { x: target.center.x + (alignmentType === 3 ? 1 : -1) * focalDistance * majorDirection.x, y: target.center.y + (alignmentType === 3 ? 1 : -1) * focalDistance * majorDirection.y }, construction: true }
|
||||
helper = helpers.get(key) ?? addHelper({ key, targetGeometryId: constraint.geometryId, alignmentType, internalAlignmentIndex: -1, webInternalGeometryIndex: constraint.internalGeometryIndex, firstPosition: alignmentType <= 2 ? 0 : 1, geometry })
|
||||
} else if (constraint.alignmentType.startsWith('hyperbola-')) {
|
||||
if (target.type !== 'arcHyperbola') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires hyperbola geometry '${constraint.geometryId}'.`)
|
||||
if (constraint.internalGeometryIndex !== 0) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' has an invalid hyperbola internal index.`)
|
||||
const alignmentType = constraint.alignmentType === 'hyperbola-major' ? 5 : constraint.alignmentType === 'hyperbola-minor' ? 6 : 7
|
||||
const key = `${constraint.geometryId}\0${constraint.alignmentType}\0${0}`
|
||||
const majorDirection = { x: Math.cos(target.rotation), y: Math.sin(target.rotation) }
|
||||
const minorDirection = { x: -majorDirection.y, y: majorDirection.x }
|
||||
const focalDistance = Math.sqrt(target.majorRadius * target.majorRadius + target.minorRadius * target.minorRadius)
|
||||
const geometry: SketchInternalGeometryHelper['geometry'] = alignmentType === 5
|
||||
? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.majorRadius * majorDirection.x, y: target.center.y + target.majorRadius * majorDirection.y }, end: { x: target.center.x - target.majorRadius * majorDirection.x, y: target.center.y - target.majorRadius * majorDirection.y }, construction: true }
|
||||
: alignmentType === 6
|
||||
? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.minorRadius * minorDirection.x, y: target.center.y + target.minorRadius * minorDirection.y }, end: { x: target.center.x - target.minorRadius * minorDirection.x, y: target.center.y - target.minorRadius * minorDirection.y }, construction: true }
|
||||
: { id: `Internal${helpers.size}`, type: 'point', position: { x: target.center.x + focalDistance * majorDirection.x, y: target.center.y + focalDistance * majorDirection.y }, construction: true }
|
||||
helper = helpers.get(key) ?? addHelper({ key, targetGeometryId: constraint.geometryId, alignmentType, internalAlignmentIndex: -1, webInternalGeometryIndex: 0, firstPosition: alignmentType <= 6 ? 0 : 1, geometry })
|
||||
} else {
|
||||
if (target.type !== 'arcParabola' || (constraint.alignmentType !== 'parabola-focus' && constraint.alignmentType !== 'parabola-focal-axis')) throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires parabola geometry '${constraint.geometryId}'.`)
|
||||
if (constraint.internalGeometryIndex !== 0) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' has an invalid parabola internal index.`)
|
||||
const alignmentType = constraint.alignmentType === 'parabola-focus' ? 8 : 11
|
||||
const key = `${constraint.geometryId}\0${constraint.alignmentType}\0${0}`
|
||||
const direction = { x: Math.cos(target.rotation), y: Math.sin(target.rotation) }
|
||||
const focus = { x: target.center.x + target.focal * direction.x, y: target.center.y + target.focal * direction.y }
|
||||
const geometry: SketchInternalGeometryHelper['geometry'] = alignmentType === 8
|
||||
? { id: `Internal${helpers.size}`, type: 'point', position: focus, construction: true }
|
||||
: { id: `Internal${helpers.size}`, type: 'line', start: { ...target.center }, end: focus, construction: true }
|
||||
helper = helpers.get(key) ?? addHelper({ key, targetGeometryId: constraint.geometryId, alignmentType, internalAlignmentIndex: -1, webInternalGeometryIndex: 0, firstPosition: alignmentType === 8 ? 1 : 0, geometry })
|
||||
}
|
||||
if (helper.explicitAlignmentId) throw new Error(`FCStd native Sketch has duplicate InternalAlignment constraints for '${constraint.geometryId}' internal geometry ${constraint.internalGeometryIndex}.`)
|
||||
helper.explicitAlignmentId = constraint.id
|
||||
@@ -746,7 +797,7 @@ const sketchConstraintPoint = (geometryIndex: Map<string, number>, geometryById:
|
||||
if (!geometry) throw new RangeError(`FCStd Sketch ${context} references unknown geometry '${reference.geometryId}'.`)
|
||||
const valid = geometry.type === 'line' ? reference.point === 'start' || reference.point === 'end'
|
||||
: geometry.type === 'point' ? reference.point === 'position'
|
||||
: geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' ? reference.point === 'center'
|
||||
: geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola' ? reference.point === 'center'
|
||||
: false
|
||||
if (!valid) throw new RangeError(`FCStd Sketch ${context} point '${reference.point}' is invalid for ${geometry.type} '${geometry.id}'.`)
|
||||
return sketchConstraintElement(geometryIndex, geometryById, reference.geometryId, sketchPointPosition[reference.point], context)
|
||||
@@ -1370,6 +1421,9 @@ const structuredPropertyValue = (element: string, elementValue: unknown, propert
|
||||
else if (type === 'Part::GeomCircle') value = { id: String(nativeId), type: 'circle', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, radius: finite(payload, 'Radius') }
|
||||
else if (type === 'Part::GeomArcOfCircle') value = { id: String(nativeId), type: 'arc', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, radius: finite(payload, 'Radius'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') }
|
||||
else if (type === 'Part::GeomEllipse') value = { id: String(nativeId), type: 'ellipse', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, majorRadius: finite(payload, 'MajorRadius'), minorRadius: finite(payload, 'MinorRadius'), rotation: finite(payload, 'AngleXU') }
|
||||
else if (type === 'Part::GeomArcOfEllipse') value = { id: String(nativeId), type: 'arcEllipse', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, majorRadius: finite(payload, 'MajorRadius'), minorRadius: finite(payload, 'MinorRadius'), rotation: finite(payload, 'AngleXU'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') }
|
||||
else if (type === 'Part::GeomArcOfHyperbola') value = { id: String(nativeId), type: 'arcHyperbola', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, majorRadius: finite(payload, 'MajorRadius'), minorRadius: finite(payload, 'MinorRadius'), rotation: finite(payload, 'AngleXU'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') }
|
||||
else if (type === 'Part::GeomArcOfParabola') value = { id: String(nativeId), type: 'arcParabola', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, focal: finite(payload, 'Focal'), rotation: finite(payload, 'AngleXU'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') }
|
||||
else if (type === 'Part::GeomBSplineCurve') {
|
||||
const curve = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
|
||||
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)
|
||||
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>()
|
||||
for (const record of constraints) {
|
||||
if (record.type !== 15) continue
|
||||
@@ -1688,11 +1742,20 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS
|
||||
if (!helper || helper.freecadInternalType !== record.internalAlignmentType || !target || record.positions[1] !== 0 || record.ids[2] !== -2000 || record.positions[2] !== 0) return undefined
|
||||
let binding: InternalHelperBinding | undefined
|
||||
if (record.internalAlignmentType === 1 || record.internalAlignmentType === 2) {
|
||||
if (helper.type !== 'line' || target.type !== 'ellipse' || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined
|
||||
if (helper.type !== 'line' || (target.type !== 'ellipse' && target.type !== 'arcEllipse') || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined
|
||||
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: record.internalAlignmentType === 1 ? 'ellipse-major' : 'ellipse-minor' }
|
||||
} else if (record.internalAlignmentType === 3 || record.internalAlignmentType === 4) {
|
||||
if (helper.type !== 'point' || target.type !== 'ellipse' || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined
|
||||
if (helper.type !== 'point' || (target.type !== 'ellipse' && target.type !== 'arcEllipse') || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined
|
||||
binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentType === 3 ? 0 : 1, alignmentType: 'ellipse-focus' }
|
||||
} else if (record.internalAlignmentType === 5 || record.internalAlignmentType === 6) {
|
||||
if (helper.type !== 'line' || target.type !== 'arcHyperbola' || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined
|
||||
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: record.internalAlignmentType === 5 ? 'hyperbola-major' : 'hyperbola-minor' }
|
||||
} else if (record.internalAlignmentType === 7) {
|
||||
if (helper.type !== 'point' || target.type !== 'arcHyperbola' || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined
|
||||
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: 'hyperbola-focus' }
|
||||
} else if (record.internalAlignmentType === 8) {
|
||||
if (helper.type !== 'point' || target.type !== 'arcParabola' || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined
|
||||
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: 'parabola-focus' }
|
||||
} else if (record.internalAlignmentType === 9) {
|
||||
if (helper.type !== 'circle' || target.type !== 'bspline' || record.positions[0] !== 3 || record.internalAlignmentIndex < 0 || record.internalAlignmentIndex >= target.controlPoints.length) return undefined
|
||||
binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentIndex, alignmentType: 'bspline-control-point' }
|
||||
@@ -1701,6 +1764,9 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS
|
||||
const uniqueKnots = bsplineExpandedKnots(target).filter((knot, index, knots) => index === 0 || knot !== knots[index - 1])
|
||||
if (record.internalAlignmentIndex < 0 || record.internalAlignmentIndex >= uniqueKnots.length) return undefined
|
||||
binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentIndex, alignmentType: 'bspline-knot' }
|
||||
} else if (record.internalAlignmentType === 11) {
|
||||
if (helper.type !== 'line' || target.type !== 'arcParabola' || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined
|
||||
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: 'parabola-focal-axis' }
|
||||
} else return undefined
|
||||
const existing = internalHelperBindings.get(helperNativeId)
|
||||
if (existing && JSON.stringify(existing) !== JSON.stringify(binding)) throw new Error(`FCStd Sketch ${objectId} internal helper ${helperNativeId} has conflicting geometry bindings.`)
|
||||
@@ -1715,7 +1781,7 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS
|
||||
const position = record.positions[slot]
|
||||
if (candidate.type === 'line') return position === 1 ? { geometryId: candidate.id, point: 'start' } : position === 2 ? { geometryId: candidate.id, point: 'end' } : undefined
|
||||
if (candidate.type === 'point') return position === 1 ? { geometryId: candidate.id, point: 'position' } : undefined
|
||||
if (candidate.type === 'circle' || candidate.type === 'arc' || candidate.type === 'ellipse') return position === 3 ? { geometryId: candidate.id, point: 'center' } : undefined
|
||||
if (candidate.type === 'circle' || candidate.type === 'arc' || candidate.type === 'ellipse' || candidate.type === 'arcEllipse' || candidate.type === 'arcHyperbola' || candidate.type === 'arcParabola') return position === 3 ? { geometryId: candidate.id, point: 'center' } : undefined
|
||||
return undefined
|
||||
}
|
||||
const geometryRef = (record: typeof constraints[number], slot: number) => geometryForNativeId(record.ids[slot])?.id
|
||||
|
||||
@@ -77,14 +77,18 @@ export { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '
|
||||
export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStage, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
|
||||
export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOcctHistoryUnavailableError, UnavailableNativeOcctHistoryProvider } from './nativeHistoryProtocol'
|
||||
export { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } from './nativeHistoryProtocol'
|
||||
export { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, nativeNamingAbiCapabilities } from './nativeHistoryProtocol'
|
||||
export { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, nativeNamingAbiCapabilities, nativeNamingCapabilitiesForModule } from './nativeHistoryProtocol'
|
||||
export type { NativeNamingAbiCapabilities, NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryInputTransport, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest, NativeOcctHistoryStageTransport } from './nativeHistoryProtocol'
|
||||
export { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest, FREECAD_PRIVATE_NAMING_ABI_VERSION, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi'
|
||||
export type { FreeCadPrivateNamingAbiDescriptor, FreeCadPrivateNamingAbiProbe, FreeCadPrivateNamingAbiRequest, NativeFreeCadNamingAbiModule } from './nativeNamingAbi'
|
||||
export { assertNativeNamingEvidence, createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, hasNativeMappedNameEvidence, validateNativeNamingEvidence } from './nativeNamingEvidence'
|
||||
export type { NativeMappedNameRef, NativeMappedNameRelation, NativeNamingEvidenceIssue, NativeNamingEvidenceReport, NativeNamingEvidenceStatus, NativeStageNamingEvidence } from './nativeNamingEvidence'
|
||||
export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient'
|
||||
export type { NativeOcctHistoryWorkerOptions } from './nativeHistoryWorkerClient'
|
||||
export { applySketchAutoConstraints, BasicSketchSolverAdapter, carbonCopySketchGeometry, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, projectSketchGeometry, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, validateSketchSnapshot } from './sketcher'
|
||||
export { applySketchAutoConstraints, BasicSketchSolverAdapter, carbonCopySketchGeometry, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, FREECAD_SKETCHER_CONSTRAINT_TYPES, FREECAD_SKETCHER_GEOMETRY_TYPES, FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES, projectSketchGeometry, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, validateSketchSnapshot } from './sketcher'
|
||||
export type { BsplineGeometryPatch, SketchAutoConstraintSuggestion, SketchConstraint, SketchDiagnostic, SketchEditorEvent, SketchEditorReplayResult, SketchExternalGeometry, SketchExternalMode, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
|
||||
export { assertPartDesignParameterSet, parameterValuesForObject, PARTDESIGN_PARAMETER_SPACE, partDesignParameterSpaceCoverage, validatePartDesignParameterSet } from './partDesignParameters'
|
||||
export type { PartDesignParameterFamily, PartDesignParameterIssue, PartDesignParameterPartition, PartDesignParameterValidation } from './partDesignParameters'
|
||||
export { assertSketchSolverRequest, assertSketchSolverResponse, BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay } from './sketchSolverProtocol'
|
||||
export type { SketchSolverCapabilities, SketchSolverCompatibility, SketchSolverExecution, SketchSolverProvider, SketchSolverReplayCase, SketchSolverReplayResult, SketchSolverRequest, SketchSolverResponse } from './sketchSolverProtocol'
|
||||
export { PLANEGCS_WASM_CAPABILITIES, PlanegcsSubsetError, solvePlanegcsSubset } from './planegcsAdapter'
|
||||
|
||||
@@ -46,6 +46,7 @@ import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replac
|
||||
import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences'
|
||||
import { createCamJob } from './cam'
|
||||
import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates'
|
||||
import { assertPartDesignParameterSet, parameterValuesForObject, validatePartDesignParameterSet } from './partDesignParameters'
|
||||
|
||||
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('ellipsoid') ? 'Part::Ellipsoid' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('torus') ? 'Part::Torus' : item.id.startsWith('helix') ? 'Part::Helix' : item.id.startsWith('prism') ? 'Part::Prism' : item.id.startsWith('wedge') ? 'Part::Wedge' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('groove') ? 'PartDesign::Groove' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('multi-transform') ? 'PartDesign::MultiTransform' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
|
||||
|
||||
@@ -145,32 +146,54 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
||||
{ name: 'SideType', label: 'Side definition', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'One side', options: ['One side', 'Two sides', 'Symmetric'], recompute: true },
|
||||
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all', 'Up to face', 'TwoLengths'], recompute: true },
|
||||
{ name: 'Type2', label: 'Type 2', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all', 'Up to face'], recompute: true },
|
||||
{ name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'UpToFace2', label: 'Reverse up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'Length2', label: 'Reverse length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true },
|
||||
{ name: 'TaperAngle', label: 'Taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
|
||||
{ name: 'TaperAngle2', label: 'Reverse taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
|
||||
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'ReferenceAxis', label: 'Reference axis', group: 'Direction', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'AlongSketchNormal', label: 'Along sketch normal', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true },
|
||||
{ name: 'UseCustomVector', label: 'Use custom vector', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'Direction', label: 'Direction', group: 'Direction', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 1 }, recompute: true },
|
||||
{ name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
|
||||
{ name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('pocket')) return [
|
||||
{ name: 'SideType', label: 'Side definition', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'One side', options: ['One side', 'Two sides', 'Symmetric'], recompute: true },
|
||||
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Through all', options: ['Dimension', 'Through all', 'Up to face', 'TwoLengths'], recompute: true },
|
||||
{ name: 'Type2', label: 'Type 2', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all', 'Up to face'], recompute: true },
|
||||
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 18, unit: 'mm', recompute: true },
|
||||
{ name: 'Length2', label: 'Reverse length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true },
|
||||
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
|
||||
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true },
|
||||
{ name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'UpToFace2', label: 'Reverse up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'TaperAngle', label: 'Taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
|
||||
{ name: 'TaperAngle2', label: 'Reverse taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
|
||||
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'ReferenceAxis', label: 'Reference axis', group: 'Direction', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'AlongSketchNormal', label: 'Along sketch normal', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true },
|
||||
{ name: 'UseCustomVector', label: 'Use custom vector', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'Direction', label: 'Direction', group: 'Direction', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 1 }, recompute: true },
|
||||
{ name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
|
||||
{ name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('revolution')) return [
|
||||
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
|
||||
{ name: 'Angle2', label: 'Reverse angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
|
||||
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Angle', options: ['Angle', 'To last', 'To first', 'Up to face', 'Two angles'], recompute: true },
|
||||
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
|
||||
{ name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'ReferenceAxis', label: 'Reference axis', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'Axis', label: 'Axis', group: 'Axis', scope: 'data', type: 'App::PropertyEnumeration', value: 'Vertical sketch axis', options: ['Horizontal sketch axis', 'Vertical sketch axis', 'Custom'], recompute: true },
|
||||
{ name: 'AxisLink', label: 'Axis link', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
|
||||
{ name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('groove')) return [
|
||||
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
|
||||
@@ -178,8 +201,14 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
||||
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Angle', options: ['Angle', 'To last', 'To first', 'Up to face', 'Two angles'], recompute: true },
|
||||
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
|
||||
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true },
|
||||
{ name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'ReferenceAxis', label: 'Reference axis', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'Axis', label: 'Axis', group: 'Axis', scope: 'data', type: 'App::PropertyEnumeration', value: 'Vertical sketch axis', options: ['Horizontal sketch axis', 'Vertical sketch axis', 'Custom'], recompute: true },
|
||||
{ name: 'AxisLink', label: 'Axis link', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
|
||||
{ name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
|
||||
{ name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('fillet')) return [
|
||||
{ name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true },
|
||||
@@ -885,6 +914,8 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
|
||||
...commonProperties(item).map((property) => property.name === 'TypeId' ? { ...property, value: typeId } : property),
|
||||
{ name: 'Base', label: 'Base edge', group: 'Chamfer', scope: 'data', type: 'App::PropertyLinkSub', value: selectedEdgeRef, recompute: true },
|
||||
{ name: 'Size', label: 'Size', group: 'Chamfer', scope: 'data', type: 'App::PropertyLength', value: 1, unit: 'mm', recompute: true },
|
||||
{ name: 'Size2', label: 'Second size', group: 'Chamfer', scope: 'data', type: 'App::PropertyLength', value: 1, unit: 'mm', recompute: true },
|
||||
{ name: 'Angle', label: 'Angle', group: 'Chamfer', scope: 'data', type: 'App::PropertyAngle', value: 45, unit: 'deg', recompute: true },
|
||||
{ name: 'ChamferType', label: 'Chamfer type', group: 'Chamfer', scope: 'data', type: 'App::PropertyEnumeration', value: 'Equal distance', options: ['Equal distance', 'Two distances', 'Distance and Angle'], recompute: true },
|
||||
{ name: 'FlipDirection', label: 'Flip direction', group: 'Chamfer', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
{ name: 'UseAllEdges', label: 'Use all edges', group: 'Chamfer', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
@@ -913,6 +944,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
|
||||
{ name: 'Value', label: 'Value', group: 'Thickness', scope: 'data', type: 'App::PropertyLength', value: 1, unit: 'mm', recompute: true },
|
||||
{ name: 'RemoveFaces', label: 'Remove faces', group: 'Thickness', scope: 'data', type: 'App::PropertyLinkSub', value: selectedFaceRef, recompute: true },
|
||||
{ name: 'Join', label: 'Join type', group: 'Thickness', scope: 'data', type: 'App::PropertyEnumeration', value: 'Arc', options: ['Arc', 'Intersection'], recompute: true },
|
||||
{ name: 'Mode', label: 'Mode', group: 'Thickness', scope: 'data', type: 'App::PropertyEnumeration', value: 'Skin', options: ['Skin', 'Pipe', 'Recto verso'], recompute: true },
|
||||
{ name: 'Reversed', label: 'Reversed', group: 'Thickness', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
|
||||
...viewProperties(),
|
||||
]
|
||||
@@ -1002,6 +1034,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
|
||||
if (commandId !== 'sweep-part' && !bodyTip) throw new RangeError('Part Design Pipe requires a valid Body Tip base feature.')
|
||||
}
|
||||
for (const property of objectSnapshot.properties.filter((candidate) => candidate.recompute && !candidate.readOnly)) validatePropertyValue(document, property, property.value)
|
||||
assertPartDesignParameterSet(objectSnapshot)
|
||||
if (commandId === 'linear-pattern' && Number(objectSnapshot.properties.find((property) => property.name === 'Length')?.value) <= 0) throw new RangeError('Linear pattern length must be greater than zero.')
|
||||
if (commandId === 'polar-pattern' && Number(objectSnapshot.properties.find((property) => property.name === 'Angle')?.value) <= 0) throw new RangeError('Polar pattern angle must be greater than zero.')
|
||||
if (commandId === 'hole') {
|
||||
@@ -1033,6 +1066,8 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
|
||||
const document = cloneDocumentSnapshot(state.document)
|
||||
const object = document.objects[objectIndex]
|
||||
object.properties[propertyIndex] = { ...object.properties[propertyIndex], value: clonePropertyValue(value), expression: undefined, expressionError: undefined }
|
||||
const parameterReport = validatePartDesignParameterSet(object.typeId, parameterValuesForObject(object), { requireComplete: false })
|
||||
if (!parameterReport.valid) throw new RangeError(`${object.typeId}.${parameterReport.issues[0].propertyName}: ${parameterReport.issues[0].message}`)
|
||||
const treeItem = document.tree.find((item) => item.id === objectId)
|
||||
if (propertyName === 'Label' && treeItem) treeItem.label = String(value)
|
||||
document.dependencies = collectDependencyEdges(document)
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctHistoryStepProvider, NativeOcctMultiTransformStep } from './nativeHistoryProvider'
|
||||
import { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi'
|
||||
|
||||
export const NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1 as const
|
||||
|
||||
export type NativeNamingAbiCapabilities = {
|
||||
mappedNameRef: 'available' | 'optional' | 'unavailable'
|
||||
stringHasher: 'available' | 'opaque-preserved' | 'unavailable'
|
||||
elementMap2: 'available' | 'opaque-preserved' | 'unavailable'
|
||||
tokenGeneration: 'native-only' | 'forbidden'
|
||||
abiVersion?: number
|
||||
freecadVersion?: string
|
||||
sourceCommit?: string
|
||||
operations?: NativeOcctHistoryOperation[]
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export const NATIVE_OCCT_NAMING_ABI_UNAVAILABLE: NativeNamingAbiCapabilities = Object.freeze({
|
||||
mappedNameRef: 'unavailable',
|
||||
stringHasher: 'unavailable',
|
||||
elementMap2: 'unavailable',
|
||||
tokenGeneration: 'forbidden',
|
||||
})
|
||||
|
||||
@@ -29,8 +37,24 @@ export type NativeOcctHistoryCapabilities = {
|
||||
export const nativeNamingAbiCapabilities = (capabilities: NativeOcctHistoryCapabilities): NativeNamingAbiCapabilities => ({
|
||||
...NATIVE_OCCT_NAMING_ABI_UNAVAILABLE,
|
||||
...(capabilities.naming ?? {}),
|
||||
...(capabilities.naming?.operations ? { operations: [...capabilities.naming.operations] } : {}),
|
||||
})
|
||||
|
||||
export const nativeNamingCapabilitiesForModule = (module: NativeOcctHistoryStepProvider): NativeNamingAbiCapabilities => {
|
||||
const probe = probeFreeCadPrivateNamingAbi(module)
|
||||
if (probe.availability !== 'available' || !probe.descriptor) return { ...NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, ...(probe.reason ? { reason: probe.reason } : {}), ...(probe.abiVersion === null ? {} : { abiVersion: probe.abiVersion }) }
|
||||
return {
|
||||
mappedNameRef: 'available',
|
||||
stringHasher: 'available',
|
||||
elementMap2: 'available',
|
||||
tokenGeneration: 'native-only',
|
||||
abiVersion: probe.abiVersion ?? undefined,
|
||||
freecadVersion: probe.descriptor.freecadVersion,
|
||||
sourceCommit: probe.descriptor.sourceCommit,
|
||||
operations: [...probe.descriptor.operations],
|
||||
}
|
||||
}
|
||||
|
||||
export type NativeOcctHistoryRequest = {
|
||||
protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION
|
||||
requestId: string
|
||||
@@ -241,6 +265,7 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide
|
||||
availability: 'available',
|
||||
operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'],
|
||||
transport: 'step-text',
|
||||
naming: nativeNamingCapabilitiesForModule(this.module),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +348,27 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide
|
||||
return this.module.booleanHistoryFromStep(request.objectStep, request.toolStep!, request.operation)
|
||||
})
|
||||
if (signal.aborted) throw abortError()
|
||||
return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history: attachStageMetadata(request, history) })
|
||||
const stagedHistory = attachStageMetadata(request, history)
|
||||
const orderedStages = [...(request.stages ?? [])].sort((left, right) => left.ordinal - right.ordinal)
|
||||
const finalStage = orderedStages.at(-1)
|
||||
const stageId = finalStage?.resultStageId ?? finalStage?.stageId ?? `${request.operationId}:stage:0`
|
||||
const namingEvidence = captureFreeCadPrivateNamingEvidence(this.module, createFreeCadPrivateNamingAbiRequest({
|
||||
requestId: request.requestId,
|
||||
documentId: request.documentId,
|
||||
documentVersion: request.documentVersion,
|
||||
operationId: request.operationId,
|
||||
operation: request.operation,
|
||||
stageId,
|
||||
resultObjectId: request.operationId,
|
||||
inputs: (request.inputs ?? [{ inputId: 'object', role: 'object', step: request.objectStep }, ...(request.toolStep ? [{ inputId: 'tool', role: 'tool', step: request.toolStep }] : [])]).map(({ inputId, role, stageId: inputStageId, step }) => ({ inputId, step, ...(role ? { role } : {}), ...(inputStageId ? { stageId: inputStageId } : {}) })),
|
||||
stages: orderedStages.map((stage) => ({ ...stage, inputIds: [...stage.inputIds] })),
|
||||
...(stagedHistory.resultStep ? { resultStep: stagedHistory.resultStep } : {}),
|
||||
...(stagedHistory.resultBrep ? { resultBrep: stagedHistory.resultBrep } : {}),
|
||||
...(request.resultStepByStage ? { resultStepByStage: { ...request.resultStepByStage } } : {}),
|
||||
history: stagedHistory,
|
||||
}))
|
||||
const enrichedHistory = namingEvidence ? { ...stagedHistory, namingEvidence } : stagedHistory
|
||||
return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history: enrichedHistory })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NativeMultiTransformHistoryStep, NativeTopologyHistoryInput, NativeTopologyHistoryRecord, ShapeHandle, SubshapeRef } from './types'
|
||||
import type { NativeStageNamingEvidence } from './nativeNamingEvidence'
|
||||
import type { NativeFreeCadNamingAbiModule } from './nativeNamingAbi'
|
||||
|
||||
export type NativeOcctMultiTransformStep = NativeMultiTransformHistoryStep
|
||||
|
||||
@@ -54,7 +55,7 @@ export type NativeOcctHistoryResponse = {
|
||||
|
||||
export type NativeOcctHistoryOperation = 'fuse' | 'cut' | 'common' | 'rotate' | 'pad' | 'pocket' | 'loft' | 'pipe' | 'revolution' | 'groove' | 'fillet' | 'chamfer' | 'hole' | 'draft' | 'thickness' | 'linear-pattern' | 'polar-pattern' | 'mirrored' | 'multi-transform'
|
||||
|
||||
export type NativeOcctHistoryStepProvider = {
|
||||
export type NativeOcctHistoryStepProvider = NativeFreeCadNamingAbiModule & {
|
||||
occtVersion(): string
|
||||
booleanHistoryFromStep(objectStep: string, toolStep: string, operation: NativeOcctHistoryOperation): NativeOcctHistoryResponse
|
||||
rotateHistoryFromStep?(shapeStep: string, axisOriginX: number, axisOriginY: number, axisOriginZ: number, axisDirectionX: number, axisDirectionY: number, axisDirectionZ: number, angleDegrees: number): NativeOcctHistoryResponse
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type { NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
|
||||
import { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, type NativeOcctHistoryCapabilities, type NativeOcctHistoryRequest, type NativeOcctHistoryProtocolResponse } from './nativeHistoryProtocol'
|
||||
import { nativeNamingCapabilitiesForModule, type NativeOcctHistoryCapabilities, type NativeOcctHistoryRequest, type NativeOcctHistoryProtocolResponse } from './nativeHistoryProtocol'
|
||||
import { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest } from './nativeNamingAbi'
|
||||
|
||||
type WorkerRequest = { type: 'initialize'; moduleUrl: string } | { type: 'capture'; request: NativeOcctHistoryRequest } | { type: 'cancel'; requestId: string } | { type: 'dispose' }
|
||||
type WorkerResponse = { type: 'ready'; capabilities: NativeOcctHistoryCapabilities } | { type: 'response'; response: NativeOcctHistoryProtocolResponse } | { type: 'error'; requestId?: string; error: string }
|
||||
@@ -11,6 +12,16 @@ let provider: NativeOcctHistoryStepProvider | null = null
|
||||
const cancelled = new Set<string>()
|
||||
|
||||
const send = (message: WorkerResponse) => scope.postMessage(message)
|
||||
const operations: NativeOcctHistoryCapabilities['operations'] = ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform']
|
||||
const capabilitiesFor = (module: NativeOcctHistoryStepProvider, occtVersion = module.occtVersion()): NativeOcctHistoryCapabilities => ({
|
||||
providerId: 'occt-native.history-step',
|
||||
providerVersion: '8.0.0-embind',
|
||||
occtVersion,
|
||||
availability: 'available',
|
||||
operations: [...operations],
|
||||
transport: 'step-text',
|
||||
naming: nativeNamingCapabilitiesForModule(module),
|
||||
})
|
||||
|
||||
const captureMultiTransform = (module: NativeOcctHistoryStepProvider, request: NativeOcctHistoryRequest) => {
|
||||
if (request.transforms && request.transforms.length >= 2) return module.multiTransformHistoryFromStep?.(request.objectStep, request.transforms)
|
||||
@@ -25,15 +36,7 @@ const initialize = async (moduleUrl: string) => {
|
||||
provider = await imported.default()
|
||||
send({
|
||||
type: 'ready',
|
||||
capabilities: {
|
||||
providerId: 'occt-native.history-step',
|
||||
providerVersion: '8.0.0-embind',
|
||||
occtVersion: provider.occtVersion(),
|
||||
availability: 'available',
|
||||
operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'],
|
||||
transport: 'step-text',
|
||||
naming: NATIVE_OCCT_NAMING_ABI_UNAVAILABLE,
|
||||
},
|
||||
capabilities: capabilitiesFor(provider),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,7 +84,25 @@ scope.onmessage = ({ data }: MessageEvent<WorkerRequest>) => {
|
||||
: provider.booleanHistoryFromStep(request.objectStep, request.toolStep || '', request.operation)
|
||||
if (!history) throw new Error('Native OCCT history provider does not expose the requested feature operation.')
|
||||
if (cancelled.delete(data.request.requestId)) return
|
||||
send({ type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: history.occtVersion, availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text', naming: NATIVE_OCCT_NAMING_ABI_UNAVAILABLE }, history } })
|
||||
const orderedStages = [...(request.stages ?? [])].sort((left, right) => left.ordinal - right.ordinal)
|
||||
const finalStage = orderedStages.at(-1)
|
||||
const stageId = finalStage?.resultStageId ?? finalStage?.stageId ?? `${request.operationId}:stage:0`
|
||||
const namingEvidence = captureFreeCadPrivateNamingEvidence(provider, createFreeCadPrivateNamingAbiRequest({
|
||||
requestId: request.requestId,
|
||||
documentId: request.documentId,
|
||||
documentVersion: request.documentVersion,
|
||||
operationId: request.operationId,
|
||||
operation: request.operation,
|
||||
stageId,
|
||||
resultObjectId: request.operationId,
|
||||
inputs: (request.inputs ?? [{ inputId: 'object', role: 'object', step: request.objectStep }, ...(request.toolStep ? [{ inputId: 'tool', role: 'tool', step: request.toolStep }] : [])]).map(({ inputId, role, stageId: inputStageId, step }) => ({ inputId, step, ...(role ? { role } : {}), ...(inputStageId ? { stageId: inputStageId } : {}) })),
|
||||
stages: orderedStages.map((stage) => ({ ...stage, inputIds: [...stage.inputIds] })),
|
||||
...(history.resultStep ? { resultStep: history.resultStep } : {}),
|
||||
...(history.resultBrep ? { resultBrep: history.resultBrep } : {}),
|
||||
...(request.resultStepByStage ? { resultStepByStage: { ...request.resultStepByStage } } : {}),
|
||||
history,
|
||||
}))
|
||||
send({ type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: capabilitiesFor(provider, history.occtVersion), history: namingEvidence ? { ...history, namingEvidence } : history } })
|
||||
} catch (error) {
|
||||
send({ type: 'error', requestId: data.type === 'capture' ? data.request.requestId : undefined, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
|
||||
136
src/facade/nativeNamingAbi.ts
Normal file
136
src/facade/nativeNamingAbi.ts
Normal 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 }
|
||||
}
|
||||
288
src/facade/partDesignParameters.ts
Normal file
288
src/facade/partDesignParameters.ts
Normal 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),
|
||||
})
|
||||
@@ -12,7 +12,7 @@ export type FacadeRuntimeProfile = {
|
||||
availability: 'configured-at-application-boundary' | 'not-configured'
|
||||
}
|
||||
naming: {
|
||||
nativeAbi: 'not-exposed'
|
||||
nativeAbi: 'freecad-private-v1-optional-worker' | 'not-exposed'
|
||||
preservation: 'opaque-preserved'
|
||||
}
|
||||
persistence: 'sqlite-opfs-with-memory-fallback'
|
||||
@@ -31,7 +31,7 @@ export const createFacadeRuntimeProfile = (mode: FacadeRuntimeMode): FacadeRunti
|
||||
availability: 'not-configured',
|
||||
},
|
||||
naming: {
|
||||
nativeAbi: 'not-exposed',
|
||||
nativeAbi: mode === 'production' ? 'freecad-private-v1-optional-worker' : 'not-exposed',
|
||||
preservation: 'opaque-preserved',
|
||||
},
|
||||
persistence: 'sqlite-opfs-with-memory-fallback',
|
||||
|
||||
@@ -8,6 +8,9 @@ export type SketchGeometry =
|
||||
| { id: string; type: 'circle'; center: SketchPoint; radius: number; construction?: boolean }
|
||||
| { id: string; type: 'arc'; center: SketchPoint; radius: number; startAngle: number; endAngle: number; construction?: boolean }
|
||||
| { id: string; type: 'ellipse'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; construction?: boolean }
|
||||
| { id: string; type: 'arcEllipse'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; startAngle: number; endAngle: number; construction?: boolean }
|
||||
| { id: string; type: 'arcHyperbola'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; startAngle: number; endAngle: number; construction?: boolean }
|
||||
| { id: string; type: 'arcParabola'; center: SketchPoint; focal: number; rotation: number; startAngle: number; endAngle: number; construction?: boolean }
|
||||
| { id: string; type: 'bspline'; degree: number; controlPoints: SketchPoint[]; weights?: number[]; knots?: number[]; periodic?: boolean; construction?: boolean }
|
||||
|
||||
export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' }
|
||||
@@ -43,7 +46,11 @@ export type SketchConstraint =
|
||||
| { id: string; type: 'weight'; geometryId: string; controlPointIndex: number; value: number; driving?: boolean }
|
||||
| { id: string; type: 'snellsLaw'; first: SketchPointRef; second: SketchPointRef; boundaryGeometryId: string; value: number; driving?: boolean }
|
||||
| { id: string; type: 'snellsLaw'; firstGeometryId: string; secondGeometryId: string; value: number; driving?: boolean }
|
||||
| { id: string; type: 'internalAlignment'; geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'bspline-control-point' | 'bspline-knot'; driving?: boolean }
|
||||
| { id: string; type: 'internalAlignment'; geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'hyperbola-major' | 'hyperbola-minor' | 'hyperbola-focus' | 'parabola-focus' | 'parabola-focal-axis' | 'bspline-control-point' | 'bspline-knot'; driving?: boolean }
|
||||
|
||||
export const FREECAD_SKETCHER_GEOMETRY_TYPES = Object.freeze(['point', 'line', 'arc', 'circle', 'ellipse', 'arcEllipse', 'arcHyperbola', 'arcParabola', 'bspline'] as const)
|
||||
export const FREECAD_SKETCHER_CONSTRAINT_TYPES = Object.freeze(['coincident', 'horizontal', 'vertical', 'parallel', 'tangent', 'distance', 'distanceX', 'distanceY', 'angle', 'perpendicular', 'radius', 'equal', 'pointOnObject', 'symmetric', 'internalAlignment', 'snellsLaw', 'block', 'diameter', 'weight'] as const)
|
||||
export const FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES = Object.freeze(['ellipse-major', 'ellipse-minor', 'ellipse-focus', 'hyperbola-major', 'hyperbola-minor', 'hyperbola-focus', 'parabola-focus', 'parabola-focal-axis', 'bspline-control-point', 'bspline-knot'] as const)
|
||||
|
||||
export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid'
|
||||
|
||||
@@ -97,10 +104,17 @@ export const validateSketchGeometry = (geometry: SketchGeometry): void => {
|
||||
if (!Number.isFinite(geometry.radius) || geometry.radius <= 0) throw new RangeError(`${geometry.id} radius must be finite and greater than zero.`)
|
||||
if (geometry.type === 'arc' && (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle))) throw new RangeError(`${geometry.id} angles must be finite.`)
|
||||
}
|
||||
if (geometry.type === 'ellipse') {
|
||||
if (geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola') {
|
||||
assertFinitePoint(geometry.center, `${geometry.id}.center`)
|
||||
if (!Number.isFinite(geometry.majorRadius) || geometry.majorRadius <= 0 || !Number.isFinite(geometry.minorRadius) || geometry.minorRadius <= 0) throw new RangeError(`${geometry.id} radii must be finite and greater than zero.`)
|
||||
if (geometry.majorRadius < geometry.minorRadius || !Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid axis parameters.`)
|
||||
if ((geometry.type === 'ellipse' || geometry.type === 'arcEllipse') && geometry.majorRadius < geometry.minorRadius) throw new RangeError(`${geometry.id} has invalid axis parameters.`)
|
||||
if (!Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid axis parameters.`)
|
||||
if (geometry.type !== 'ellipse' && (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle) || geometry.startAngle === geometry.endAngle)) throw new RangeError(`${geometry.id} parameter range must contain two distinct finite values.`)
|
||||
}
|
||||
if (geometry.type === 'arcParabola') {
|
||||
assertFinitePoint(geometry.center, `${geometry.id}.center`)
|
||||
if (!Number.isFinite(geometry.focal) || geometry.focal <= 0 || !Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid parabola parameters.`)
|
||||
if (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle) || geometry.startAngle === geometry.endAngle) throw new RangeError(`${geometry.id} parameter range must contain two distinct finite values.`)
|
||||
}
|
||||
if (geometry.type === 'bspline') {
|
||||
if (!Number.isSafeInteger(geometry.degree) || geometry.degree < 1) throw new RangeError(`${geometry.id} degree must be a positive integer.`)
|
||||
@@ -148,7 +162,7 @@ export const sketchGeometrySignature = (geometry: SketchGeometry): string => {
|
||||
export const cloneSketchGeometry = (geometry: SketchGeometry): SketchGeometry => {
|
||||
validateSketchGeometry(geometry)
|
||||
if (geometry.type === 'line') return { ...geometry, start: { ...geometry.start }, end: { ...geometry.end } }
|
||||
if (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') return { ...geometry, center: { ...geometry.center } }
|
||||
if (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola') return { ...geometry, center: { ...geometry.center } }
|
||||
if (geometry.type === 'bspline') return { ...geometry, controlPoints: geometry.controlPoints.map((point) => ({ ...point })), weights: geometry.weights ? [...geometry.weights] : undefined, knots: geometry.knots ? [...geometry.knots] : undefined }
|
||||
return { ...geometry, position: { ...geometry.position } }
|
||||
}
|
||||
@@ -530,7 +544,7 @@ const findGeometry = (geometry: SketchGeometry[], id: string, constraintId: stri
|
||||
const pointFor = (geometry: SketchGeometry, point: SketchPointRef['point'], constraintId: string, diagnostics: SketchDiagnostic[]): SketchPoint | null => {
|
||||
if (geometry.type === 'point' && point === 'position') return geometry.position
|
||||
if (geometry.type === 'line' && (point === 'start' || point === 'end')) return point === 'start' ? geometry.start : geometry.end
|
||||
if ((geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') && point === 'center') return geometry.center
|
||||
if ((geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola') && point === 'center') return geometry.center
|
||||
diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` })
|
||||
return null
|
||||
}
|
||||
@@ -583,7 +597,7 @@ const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], n
|
||||
if (isBlocked(geometry.id, blocked)) return
|
||||
if (geometry.type === 'point') { geometry.position = { ...next }; return }
|
||||
if (geometry.type === 'line') { if (point === 'start') geometry.start = { ...next }; else if (point === 'end') geometry.end = { ...next }; return }
|
||||
if (point === 'center' && (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse')) geometry.center = { ...next }
|
||||
if (point === 'center' && (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola')) geometry.center = { ...next }
|
||||
}
|
||||
|
||||
const validateConstraintValues = (constraint: SketchConstraint, diagnostics: SketchDiagnostic[]) => {
|
||||
@@ -684,7 +698,7 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
|
||||
const snapshot = cloneSketch(input)
|
||||
const diagnostics: SketchDiagnostic[] = []
|
||||
for (const geometry of snapshot.geometry) {
|
||||
if (geometry.type === 'ellipse' || geometry.type === 'bspline') diagnostics.push({ code: 'UNSUPPORTED_GEOMETRY', geometryId: geometry.id, message: `The typescript-basic solver does not solve ${geometry.type} geometry '${geometry.id}'.` })
|
||||
if (!['point', 'line', 'circle', 'arc'].includes(geometry.type)) diagnostics.push({ code: 'UNSUPPORTED_GEOMETRY', geometryId: geometry.id, message: `The typescript-basic solver does not solve ${geometry.type} geometry '${geometry.id}'.` })
|
||||
}
|
||||
for (const constraint of snapshot.constraints) if (constraint.type === 'weight' || constraint.type === 'snellsLaw' || constraint.type === 'internalAlignment') diagnostics.push({ code: 'UNSUPPORTED_CONSTRAINT', constraintId: constraint.id, message: `The typescript-basic solver does not solve ${constraint.type} constraint '${constraint.id}'.` })
|
||||
const geometryById = new Map(snapshot.geometry.map((geometry) => [geometry.id, geometry]))
|
||||
@@ -829,6 +843,8 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
|
||||
if (geometry.type === 'line') return 4
|
||||
if (geometry.type === 'circle') return 3
|
||||
if (geometry.type === 'arc' || geometry.type === 'ellipse') return 5
|
||||
if (geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola') return 7
|
||||
if (geometry.type === 'arcParabola') return 6
|
||||
return geometry.controlPoints.length * 2 + (geometry.weights?.length ?? 0)
|
||||
}
|
||||
const variableCount = snapshot.geometry.reduce((count, geometry) => count + variableCountForGeometry(geometry), 0)
|
||||
|
||||
@@ -17,9 +17,11 @@ import { runTopologyMutationReplay } from '../src/facade/topologyReplay'
|
||||
import { captureNativeTopologyHistory, captureNativeTopologyHistoryStages, captureSignatureTopologyHistory, composeNativeTopologyHistoryLineage } from '../src/facade/topologyHistory'
|
||||
import { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '../src/facade/nativeHistoryProvider'
|
||||
import { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, nativeNamingAbiCapabilities, type NativeOcctHistoryProvider } from '../src/facade/nativeHistoryProtocol'
|
||||
import { probeFreeCadPrivateNamingAbi } from '../src/facade/nativeNamingAbi'
|
||||
import { NativeOcctHistoryWorkerProvider } from '../src/facade/nativeHistoryWorkerClient'
|
||||
import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy'
|
||||
import { applySketchAutoConstraints, cloneSketch, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, type SketchGeometry } from '../src/facade/sketcher'
|
||||
import { applySketchAutoConstraints, cloneSketch, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, FREECAD_SKETCHER_CONSTRAINT_TYPES, FREECAD_SKETCHER_GEOMETRY_TYPES, FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, type SketchGeometry } from '../src/facade/sketcher'
|
||||
import { PARTDESIGN_PARAMETER_SPACE, partDesignParameterSpaceCoverage, validatePartDesignParameterSet } from '../src/facade/partDesignParameters'
|
||||
import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol'
|
||||
import { solvePlanegcsSubset, type PlanegcsWasmModule } from '../src/facade/planegcsAdapter'
|
||||
import { PlanegcsWorkerProvider } from '../src/facade/planegcsWorkerClient'
|
||||
@@ -87,7 +89,7 @@ test('production facade starts from an empty document unless an application boot
|
||||
assert.deepEqual(state.selectedObjectIds, [])
|
||||
assert.equal(facade.runtime.mode, 'production')
|
||||
assert.equal(facade.runtime.geometry.compatibility, 'none')
|
||||
assert.equal(facade.runtime.naming.nativeAbi, 'not-exposed')
|
||||
assert.equal(facade.runtime.naming.nativeAbi, 'freecad-private-v1-optional-worker')
|
||||
facade.geometry.dispose()
|
||||
})
|
||||
|
||||
@@ -684,7 +686,7 @@ test('native OCCT history protocol validates STEP context and isolates stale gen
|
||||
resultStep: objectStep, records: [{ relation: operation === 'cut' ? 'modified' : 'generated', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }],
|
||||
}),
|
||||
})
|
||||
assert.deepEqual(provider.capabilities(), { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text' })
|
||||
assert.deepEqual(provider.capabilities(), { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text', naming: { mappedNameRef: 'unavailable', stringHasher: 'unavailable', elementMap2: 'unavailable', tokenGeneration: 'forbidden', reason: 'Native module does not export the versioned FreeCAD naming callbacks.' } })
|
||||
const coordinator = new NativeOcctHistoryCoordinator(() => 2)
|
||||
const execution = await coordinator.capture(provider, { documentId: 'doc', documentVersion: 2, operationId: 'cut-1', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' })
|
||||
assert.equal(execution.status, 'completed')
|
||||
@@ -699,8 +701,38 @@ test('native OCCT history protocol validates STEP context and isolates stale gen
|
||||
|
||||
test('native naming ABI capabilities forbid synthetic FreeCAD tokens unless a provider declares native evidence', () => {
|
||||
const base = { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available' as const, operations: ['cut' as const], transport: 'step-text' as const }
|
||||
assert.deepEqual(nativeNamingAbiCapabilities(base), { mappedNameRef: 'unavailable', stringHasher: 'unavailable', tokenGeneration: 'forbidden' })
|
||||
assert.deepEqual(nativeNamingAbiCapabilities({ ...base, naming: { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', tokenGeneration: 'native-only' } }), { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', tokenGeneration: 'native-only' })
|
||||
assert.deepEqual(nativeNamingAbiCapabilities(base), { mappedNameRef: 'unavailable', stringHasher: 'unavailable', elementMap2: 'unavailable', tokenGeneration: 'forbidden' })
|
||||
assert.deepEqual(nativeNamingAbiCapabilities({ ...base, naming: { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', elementMap2: 'opaque-preserved', tokenGeneration: 'native-only' } }), { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', elementMap2: 'opaque-preserved', tokenGeneration: 'native-only' })
|
||||
})
|
||||
|
||||
test('versioned FreeCAD private naming ABI is capability-gated and attaches validated native evidence', async () => {
|
||||
const elementMap2 = parseElementMap2([
|
||||
'BeginElementMap v1', '1 PostfixCount 1', 'Edge', 'MapCount 1', 'ElementMap 1 1 1', 'Edge',
|
||||
'ChildCount 1', '1 0 1 0 0 Edge 0.54', 'NameCount 1', '$#36:2.1.37 0', 'EndMap', '',
|
||||
].join('\n'))
|
||||
const stringHasher = parseStringHasherTable(['StringTableStart v1 2', '-36.0 0:prefix', '-1.0 0:suffix', ''].join('\n'))
|
||||
let capturedRequest: Record<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 () => {
|
||||
@@ -713,6 +745,27 @@ test('native OCCT history coordinator reports timeout and cancellation', async (
|
||||
assert.equal((await pending).status, 'cancelled')
|
||||
})
|
||||
|
||||
test('Sketcher and supported PartDesign parameter contracts cover every declared native partition', () => {
|
||||
assert.deepEqual(FREECAD_SKETCHER_GEOMETRY_TYPES, ['point', 'line', 'arc', 'circle', 'ellipse', 'arcEllipse', 'arcHyperbola', 'arcParabola', 'bspline'])
|
||||
assert.equal(FREECAD_SKETCHER_CONSTRAINT_TYPES.length, 19)
|
||||
assert.equal(FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES.length, 10)
|
||||
assert.equal(new Set(FREECAD_SKETCHER_CONSTRAINT_TYPES).size, FREECAD_SKETCHER_CONSTRAINT_TYPES.length)
|
||||
assert.equal(new Set(FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES).size, FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES.length)
|
||||
assert.deepEqual(partDesignParameterSpaceCoverage(), { schemaVersion: 1, baseline: 'FreeCAD 1.1.1', families: 21, properties: 202, partitions: 72, duplicateTypeIds: 0, familiesWithoutPartitions: [] })
|
||||
for (const family of PARTDESIGN_PARAMETER_SPACE) {
|
||||
assert.equal(new Set(family.properties).size, family.properties.length, `${family.typeId} has duplicate properties`)
|
||||
assert.equal(new Set(family.partitions.map((entry) => entry.id)).size, family.partitions.length, `${family.typeId} has duplicate partitions`)
|
||||
for (const partition of family.partitions) for (const propertyName of Object.keys(partition.values)) assert.ok(family.properties.includes(propertyName), `${family.typeId}.${partition.id} uses undeclared ${propertyName}`)
|
||||
}
|
||||
|
||||
const validPad = validatePartDesignParameterSet('PartDesign::Pad', { Profile: 'Sketch', Length: 10, Length2: 5, Type: 'TwoLengths', Type2: 'Dimension', SideType: 'Two sides', UpToFace: null, UpToFace2: null, TaperAngle: 0, TaperAngle2: 0, Reversed: false, Midplane: false, ReferenceAxis: null, AlongSketchNormal: true, UseCustomVector: false, Direction: { x: 0, y: 0, z: 1 }, Offset: 0, Offset2: 0 })
|
||||
assert.equal(validPad.valid, true)
|
||||
assert.equal(validatePartDesignParameterSet('PartDesign::Pad', { ...Object.fromEntries(PARTDESIGN_PARAMETER_SPACE.find((entry) => entry.typeId === 'PartDesign::Pad')!.properties.map((name) => [name, null])), Type: 'TwoLengths', SideType: 'Two sides', Length: 10, Length2: 5, Midplane: true, TaperAngle: 0, TaperAngle2: 0 }).valid, false)
|
||||
assert.equal(validatePartDesignParameterSet('PartDesign::Hole', { Diameter: 5, DepthType: 'ThroughAll', Direction: { x: 0, y: 0, z: 1 }, HoleCutType: 'None', Threaded: false, ModelThread: true }, { requireComplete: false }).issues.some((issue) => issue.propertyName === 'ModelThread'), true)
|
||||
assert.equal(validatePartDesignParameterSet('PartDesign::Draft', { Base: 'Pad', Angle: 5, Reversed: false }, { requireComplete: false }).valid, true)
|
||||
assert.equal(validatePartDesignParameterSet('PartDesign::LinearPattern', { Originals: ['Pad'], Occurrences: 2, Mode: 'Extent', Length: 10, Direction: 'Horizontal' }, { requireComplete: false }).valid, true)
|
||||
})
|
||||
|
||||
test('native OCCT Worker provider preserves versioned request and response context', async () => {
|
||||
const messageListeners = new Set<(event: MessageEvent) => void>()
|
||||
const errorListeners = new Set<(event: ErrorEvent) => void>()
|
||||
@@ -4666,6 +4719,33 @@ test('FCStd Sketcher codec round-trips ellipse and B-spline knot internal geomet
|
||||
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: invalidEllipseIndex }] }), /invalid ellipse internal index/)
|
||||
})
|
||||
|
||||
test('FCStd Sketcher codec round-trips all conic arc geometry and private internal alignments', () => {
|
||||
const sketch = createSketch('Sketch', [
|
||||
{ id: 'arc-ellipse', type: 'arcEllipse', center: { x: 1, y: 2 }, majorRadius: 4, minorRadius: 2, rotation: 0.2, startAngle: -1, endAngle: 1 },
|
||||
{ id: 'arc-hyperbola', type: 'arcHyperbola', center: { x: 8, y: 2 }, majorRadius: 3, minorRadius: 1.5, rotation: 0.4, startAngle: -0.8, endAngle: 0.8 },
|
||||
{ id: 'arc-parabola', type: 'arcParabola', center: { x: 14, y: 2 }, focal: 2, rotation: 0.6, startAngle: -1.2, endAngle: 1.2 },
|
||||
], [
|
||||
{ id: 'ellipse-major', type: 'internalAlignment', geometryId: 'arc-ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-major' },
|
||||
{ id: 'hyperbola-major', type: 'internalAlignment', geometryId: 'arc-hyperbola', internalGeometryIndex: 0, alignmentType: 'hyperbola-major' },
|
||||
{ id: 'hyperbola-minor', type: 'internalAlignment', geometryId: 'arc-hyperbola', internalGeometryIndex: 0, alignmentType: 'hyperbola-minor' },
|
||||
{ id: 'hyperbola-focus', type: 'internalAlignment', geometryId: 'arc-hyperbola', internalGeometryIndex: 0, alignmentType: 'hyperbola-focus' },
|
||||
{ id: 'parabola-focus', type: 'internalAlignment', geometryId: 'arc-parabola', internalGeometryIndex: 0, alignmentType: 'parabola-focus' },
|
||||
{ id: 'parabola-axis', type: 'internalAlignment', geometryId: 'arc-parabola', internalGeometryIndex: 0, alignmentType: 'parabola-focal-axis' },
|
||||
])
|
||||
const document = recomputeDocumentFixture()
|
||||
document.objects = [{ id: 'Sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch }]
|
||||
const archive = serializeFcstdMetadataArchive(document)
|
||||
const written = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
||||
assert.match(written, /type="Part::GeomArcOfEllipse"/)
|
||||
assert.match(written, /type="Part::GeomArcOfHyperbola"/)
|
||||
assert.match(written, /type="Part::GeomArcOfParabola"/)
|
||||
assert.deepEqual([...written.matchAll(/InternalAlignmentType="(\d+)"/g)].map((match) => Number(match[1])), [1, 5, 6, 7, 8, 11])
|
||||
const restored = inspectFcstdArchive(archive).objects[0].sketch
|
||||
assert.ok(restored)
|
||||
assert.deepEqual(restored.geometry, sketch.geometry)
|
||||
assert.deepEqual(restored.constraints, sketch.constraints.map((constraint) => ({ ...constraint, driving: true })))
|
||||
})
|
||||
|
||||
test('FCStd Sketcher codec round-trips native external Edge/Vertex projections and stable TopoRefs', () => {
|
||||
const sketch = createSketch('Sketch', [{ id: 'profile', type: 'line', start: { x: 0, y: 0 }, end: { x: 2, y: 0 } }])
|
||||
const edgeSource = { schemaVersion: 1 as const, objectId: 'Source', kind: 'edge' as const, persistentId: 'Edge1', topologyVersion: 7, generation: 4, status: 'stable' as const, signature: 'edge-signature', candidates: ['Edge1'] }
|
||||
|
||||
Reference in New Issue
Block a user