feat: align FreeCAD property status semantics
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled

This commit is contained in:
2026-08-14 17:56:41 -04:00
parent f64b78865c
commit e3373c9d6c
28 changed files with 811 additions and 61 deletions

View File

@@ -10,6 +10,7 @@ FreeCAD-aligned web CAD frontend baseline for the BitBybit runtime boundary.
- Three.js `0.185.1` is pinned for the internal Viewport Adapter; React does not import Three.js directly. - Three.js `0.185.1` is pinned for the internal Viewport Adapter; React does not import Three.js directly.
- FreeCAD-style application menus, workbench navigation, Combo View, Task Dock, viewport and Report view - FreeCAD-style application menus, workbench navigation, Combo View, Task Dock, viewport and Report view
- Machine-readable workbench and command manifest in `src/freecadManifest.ts` - Machine-readable workbench and command manifest in `src/freecadManifest.ts`
- Current status, source-of-truth order and focused execution queue in [docs/current-work.zh-CN.md](docs/current-work.zh-CN.md)
- Chinese implementation plan in `docs/web-cad-implementation-plan.zh-CN.md` - Chinese implementation plan in `docs/web-cad-implementation-plan.zh-CN.md`
- Full 34-module FreeCAD 1.1.1 scope, Bitbybit/OCCT source-build track and executable task ledger in [docs/freecad-complete-parity-backlog.zh-CN.md](docs/freecad-complete-parity-backlog.zh-CN.md) - Full 34-module FreeCAD 1.1.1 scope, Bitbybit/OCCT source-build track and executable task ledger in [docs/freecad-complete-parity-backlog.zh-CN.md](docs/freecad-complete-parity-backlog.zh-CN.md)
- Executable reference-oracle and R0-R9 delivery order in [docs/freecad-reference-execution-roadmap.zh-CN.md](docs/freecad-reference-execution-roadmap.zh-CN.md) - Executable reference-oracle and R0-R9 delivery order in [docs/freecad-reference-execution-roadmap.zh-CN.md](docs/freecad-reference-execution-roadmap.zh-CN.md)
@@ -18,7 +19,7 @@ FreeCAD-aligned web CAD frontend baseline for the BitBybit runtime boundary.
- Source-traceable TypeId/property candidate inventory in [config/freecad-type-property-inventory.json](config/freecad-type-property-inventory.json), checked with `./npmw run check:freecad-type-properties` - Source-traceable TypeId/property candidate inventory in [config/freecad-type-property-inventory.json](config/freecad-type-property-inventory.json), checked with `./npmw run check:freecad-type-properties`
- Locked FreeCAD 1.1.1 planegcs WASM subset with Dedicated Worker verification for line/circle/curve constraints, SnellsLaw, Ellipse InternalAlignment helpers and clamped cubic B-spline Weight - Locked FreeCAD 1.1.1 planegcs WASM subset with Dedicated Worker verification for line/circle/curve constraints, SnellsLaw, Ellipse InternalAlignment helpers and clamped cubic B-spline Weight
The current milestone is a facade-first integration slice. Part Design document transactions and history are executable in the mock domain adapter; the internal SQLite WASM + OPFS Persistence Worker, schema and in-memory fallback are bundled behind `facade.project`. FreeCAD/OCCT geometry, solver behavior, FCStd interoperability and full workbench semantics remain staged behind the documented P3-P7 gates. The compatible browser baseline now spans all 34 locked FreeCAD modules, but it is not a system-level exact implementation: the release matrix currently records `0 exact`, `26 compatible`, `5 proxy` and `3 development` modules. The active milestone is exact-parity promotion for the FreeCAD 1.1.1 oracle, topology naming, document/property semantics, core modeling and FCStd round-trip. See the [current work focus](docs/current-work.zh-CN.md); completed P01-P10 compatibility programs must not be interpreted as an exact FreeCAD claim.
## Development ## Development

View File

@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": "2026-08-11T04:50:53.969Z", "generatedAt": "2026-08-14T14:24:04.328Z",
"status": "pass", "status": "pass",
"browsers": [ "browsers": [
{ {
@@ -38,7 +38,7 @@
"height": 579, "height": 579,
"geometrySource": "bitbybit-occt" "geometrySource": "bitbybit-occt"
}, },
"screenshotBytes": 128023, "screenshotBytes": 121616,
"pageErrors": [] "pageErrors": []
} }
] ]

View File

@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": "2026-08-11T02:37:40.468Z", "generatedAt": "2026-08-14T14:26:21.996Z",
"browserId": "chrome", "browserId": "chrome",
"status": "pass", "status": "pass",
"browser": { "browser": {
@@ -423,18 +423,18 @@
"screenshots": { "screenshots": {
"desktop": { "desktop": {
"path": "fixtures/chrome-app-e2e/desktop-workspace.png", "path": "fixtures/chrome-app-e2e/desktop-workspace.png",
"byteLength": 108743, "byteLength": 109165,
"sha256": "dd67ab23283ffd55dc0509174f8636eac5344de581d4cacfff13cc1828617399" "sha256": "f5030d4e3ae88107e06a5e8147a196b5f0eb7748c7c306b4118f97a98dd9bee4"
}, },
"cam": { "cam": {
"path": "fixtures/chrome-app-e2e/cam-workbench.png", "path": "fixtures/chrome-app-e2e/cam-workbench.png",
"byteLength": 118389, "byteLength": 118390,
"sha256": "c339e4b998b8ce7b3290c32efeffebed85dbd1d56d8ae9f764196c7b52e80a8f" "sha256": "5349262af666380bbc96f2932eed2613547ae60870a1d8b9fe44d8ed4831b4ab"
}, },
"mobile": { "mobile": {
"path": "fixtures/chrome-app-e2e/mobile-start.png", "path": "fixtures/chrome-app-e2e/mobile-start.png",
"byteLength": 130371, "byteLength": 130361,
"sha256": "9d69c9e9ced4b45bbd083a37a94b5df074e8d60540c0857e79b9df08a8648d81" "sha256": "c65c8644728cb488b22d683a3b68b770dccdfe2107dc9ab5413bfdda72ff9ab1"
} }
}, },
"pageErrors": [] "pageErrors": []

View File

@@ -30,7 +30,7 @@
"scale": { "scale": {
"components": 1000, "components": 1000,
"bomRows": 10, "bomRows": 10,
"durationMs": 6.685000061988831, "durationMs": 11.384999990463257,
"budgetMs": 1000 "budgetMs": 1000
}, },
"fcstd": { "fcstd": {
@@ -44,7 +44,7 @@
"persistence": { "persistence": {
"mode": "sqlite-opfs", "mode": "sqlite-opfs",
"byteLength": 980, "byteLength": 980,
"hash": "93bbadc1517ccf2443c74578e92bea6d13c01b02976a53630707f5e7002a6066", "hash": "5482e205a876ef4516106d3819ee59a6873ada3f6e48d85df78ed123c073fd3b",
"roundTrip": true, "roundTrip": true,
"released": true "released": true
}, },

View File

@@ -24,7 +24,7 @@
"persistence": { "persistence": {
"mode": "sqlite-opfs", "mode": "sqlite-opfs",
"byteLength": 848, "byteLength": 848,
"hash": "7a55c8b5c5f1f5354c2819c6289c9ae60351f7c0d6d1844e51eea2785ce7fad8", "hash": "ec7b7361b0ea5caad7776e5cf851909745150e0de004840187ce000d268794e0",
"roundTrip": true, "roundTrip": true,
"released": true "released": true
}, },

View File

@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"status": "pass", "status": "failed",
"stages": [ "stages": [
"CAD", "CAD",
"OCL", "OCL",
@@ -29,15 +29,7 @@
"hasC359": true, "hasC359": true,
"hasC361": true "hasC361": true
}, },
"machine": { "error": "Error: LinuxCNC WASM bridge timed out waiting for parser/controller acceptance.\n at http://127.0.0.1:5201/src/facade/camPipeline.ts:103:18",
"backend": "linuxcnc-wasm",
"status": "accepted",
"dryRunStatus": "dry-run",
"parserAuthority": "linuxcnc-wasm",
"lines": 65,
"blocks": 138,
"trajectorySegments": 48
},
"browserId": "chrome", "browserId": "chrome",
"browser": { "browser": {
"product": "150.0.7871.128", "product": "150.0.7871.128",

View File

@@ -21,7 +21,7 @@
"multiAxis": true "multiAxis": true
}, },
"geometry": { "geometry": {
"sourceShape": "shape-msjl38sz-1", "sourceShape": "shape-mst1ison-1",
"valid": true, "valid": true,
"volume": 30 "volume": 30
}, },

View File

@@ -153,7 +153,7 @@
}, },
"facadeCapabilities": { "facadeCapabilities": {
"geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "object-placement-recompute", "placement-validation", "mirror-plane", "mirrored-whole-shape", "multi-transform-whole-shape", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "loft", "pipe", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "native-cross-feature-history-routing", "step-export", "stl-export"], "featureLevels": { "pad": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "pocket": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "revolution": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "groove": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it", "OCCT/FreeCAD private token generation"] }, "boolean": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it", "isomorphic source ambiguity"] }, "fillet": { "level": "experimental", "exactBlockedBy": ["complete cross-feature native stage history"] }, "chamfer": { "level": "experimental", "exactBlockedBy": ["complete cross-feature native stage history"] }, "multi-transform": { "level": "experimental", "exactBlockedBy": ["feature-list native history", "private naming tokens"] } } }, "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "object-placement-recompute", "placement-validation", "mirror-plane", "mirrored-whole-shape", "multi-transform-whole-shape", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "loft", "pipe", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "native-cross-feature-history-routing", "step-export", "stl-export"], "featureLevels": { "pad": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "pocket": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "revolution": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "groove": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it", "OCCT/FreeCAD private token generation"] }, "boolean": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it", "isomorphic source ambiguity"] }, "fillet": { "level": "experimental", "exactBlockedBy": ["complete cross-feature native stage history"] }, "chamfer": { "level": "experimental", "exactBlockedBy": ["complete cross-feature native stage history"] }, "multi-transform": { "level": "experimental", "exactBlockedBy": ["feature-list native history", "private naming tokens"] } } },
"document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-multi-transform", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "adjacency-fingerprint-disambiguation", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "native-topology-history-adapter", "native-cross-feature-history-routing", "runtime-mapped-name-ref-evidence", "string-hasher-evidence-validation", "element-map2-multi-stage-name-mapping", "isomorphic-candidate-persistence", "element-map-name-contract", "undo-redo"] }, "document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-multi-transform", "property-link-list", "property-string-list", "property-link-sub", "freecad-property-status-bitmask", "property-status-native-oracle", "property-status-output-touch-suppression", "property-status-dependent-only-recompute", "property-status-transient-fcstd", "property-status-no-persist-fcstd", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "adjacency-fingerprint-disambiguation", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "native-topology-history-adapter", "native-cross-feature-history-routing", "runtime-mapped-name-ref-evidence", "string-hasher-evidence-validation", "element-map2-multi-stage-name-mapping", "isomorphic-candidate-persistence", "element-map-name-contract", "undo-redo"] },
"persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v7", "stable-native-object-tags", "object-topology-snapshots", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] }, "persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v7", "stable-native-object-tags", "object-topology-snapshots", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] },
"sketcher": { "level": "experimental", "provider": "typescript-basic; FreeCAD 1.1.1 planegcs WASM core installed", "operations": ["point-line-circle-arc-model", "ellipse-bspline-periodic-model", "construction-geometry", "geometry-validation", "stable-geometry-signature", "stable-external-geometry-model", "basic-constraints", "advanced-constraint-proxy", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "planegcs-native-system-solve", "planegcs-horizontal-distance", "planegcs-vertical-distance", "planegcs-distance-x", "planegcs-distance-y", "planegcs-angle", "planegcs-circle-radius", "planegcs-circle-diameter", "planegcs-equal-lines", "planegcs-equal-circles", "planegcs-tangent-circles", "planegcs-point-symmetry", "planegcs-point-on-object", "planegcs-point-on-arc", "planegcs-point-on-ellipse", "planegcs-point-on-cubic-bspline", "planegcs-block-freeze", "planegcs-parallel-lines", "planegcs-perpendicular-lines", "planegcs-coincident-lines", "planegcs-snells-law-lines", "planegcs-ellipse-internal-alignment", "planegcs-cubic-bspline-weight", "planegcs-dedicated-worker-smoke", "persistence"] }, "sketcher": { "level": "experimental", "provider": "typescript-basic; FreeCAD 1.1.1 planegcs WASM core installed", "operations": ["point-line-circle-arc-model", "ellipse-bspline-periodic-model", "construction-geometry", "geometry-validation", "stable-geometry-signature", "stable-external-geometry-model", "basic-constraints", "advanced-constraint-proxy", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "planegcs-native-system-solve", "planegcs-horizontal-distance", "planegcs-vertical-distance", "planegcs-distance-x", "planegcs-distance-y", "planegcs-angle", "planegcs-circle-radius", "planegcs-circle-diameter", "planegcs-equal-lines", "planegcs-equal-circles", "planegcs-tangent-circles", "planegcs-point-symmetry", "planegcs-point-on-object", "planegcs-point-on-arc", "planegcs-point-on-ellipse", "planegcs-point-on-cubic-bspline", "planegcs-block-freeze", "planegcs-parallel-lines", "planegcs-perpendicular-lines", "planegcs-coincident-lines", "planegcs-snells-law-lines", "planegcs-ellipse-internal-alignment", "planegcs-cubic-bspline-weight", "planegcs-dedicated-worker-smoke", "persistence"] },
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "document-property-projection", "live-topology-element-map", "proxy-report", "script-isolation"] }, "fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "document-property-projection", "live-topology-element-map", "proxy-report", "script-isolation"] },

View File

@@ -862,9 +862,6 @@
], ],
"facadeNative": [ "facadeNative": [
"Hidden", "Hidden",
"ReadOnly"
],
"proxyOnly": [
"Immutable", "Immutable",
"LockDynamic", "LockDynamic",
"NoModify", "NoModify",
@@ -877,17 +874,39 @@
"PropOutput", "PropOutput",
"PropReadOnly", "PropReadOnly",
"PropTransient", "PropTransient",
"ReadOnly",
"Transient" "Transient"
], ],
"proxyOnlyRecordCount": 2639, "behaviorNative": [
"Hidden",
"Immutable",
"NoModify",
"Ordered",
"Output",
"PropHidden",
"PropNoPersist",
"PropNoRecompute",
"PropOutput",
"PropReadOnly",
"PropTransient",
"ReadOnly",
"Transient"
],
"preservedOnly": [
"LockDynamic",
"PartialTrigger"
],
"preservedOnlyRecordCount": 70,
"proxyOnly": [],
"proxyOnlyRecordCount": 0,
"unknownNumericBits": [] "unknownNumericBits": []
}, },
"harness": { "harness": {
"path": "scripts/run-freecad-native-property-semantics.mjs", "path": "scripts/run-freecad-native-property-semantics.mjs",
"bytes": 5658, "bytes": 5949,
"sha256": "35eb62dc7988ac5f373fb25d1558ab830ecb42b261009fbd79638ee428800331" "sha256": "fb7fb74e4979b284e7a0f637d820bd183cea8e5cfb1348fd1730a891ac1306a2"
}, },
"exactPromotionReady": false, "exactPromotionReady": false,
"exactBlocker": "complete native document and property semantics", "exactBlocker": "complete native document and property semantics",
"generatedAt": "2026-08-14T12:20:59.613Z" "generatedAt": "2026-08-14T15:36:12.139Z"
} }

View File

@@ -0,0 +1,223 @@
{
"baselineId": "freecad-1.1.1-property-status-oracle",
"cases": [
{
"afterRecompute": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"attr": 0,
"before": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"changed": {
"documentTouched": true,
"mustExecute": false,
"state": [
"Touched"
]
},
"id": "ordinary",
"recomputeCount": 1,
"reportedStatus": [
"21"
],
"reportedType": [],
"runtimeStatus": null
},
{
"afterRecompute": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"attr": 0,
"before": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"changed": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"id": "runtime-output",
"recomputeCount": 0,
"reportedStatus": [
"Output",
"21"
],
"reportedType": [],
"runtimeStatus": "Output"
},
{
"afterRecompute": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"attr": 8,
"before": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"changed": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"id": "type-output",
"recomputeCount": 0,
"reportedStatus": [
"21",
"27"
],
"reportedType": [
"Output"
],
"runtimeStatus": null
},
{
"afterRecompute": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"attr": 0,
"before": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"changed": {
"documentTouched": true,
"mustExecute": false,
"state": [
"Touched"
]
},
"id": "runtime-no-recompute",
"recomputeCount": 1,
"reportedStatus": [
"NoRecompute",
"21"
],
"reportedType": [],
"runtimeStatus": "NoRecompute"
},
{
"afterRecompute": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"attr": 16,
"before": {
"documentTouched": false,
"mustExecute": false,
"state": [
"Up-to-date"
]
},
"changed": {
"documentTouched": true,
"mustExecute": false,
"state": [
"Touched"
]
},
"id": "type-no-recompute",
"recomputeCount": 0,
"reportedStatus": [
"21",
"23"
],
"reportedType": [
"NoRecompute"
],
"runtimeStatus": null
}
],
"fcstd": {
"reopened": {
"properties": [
"Persisted",
"RuntimeTransient",
"TypeTransient"
],
"statuses": {
"Persisted": [
"Output",
"21"
],
"RuntimeTransient": [
"Transient",
"21"
],
"TypeTransient": [
"21",
"25"
]
},
"values": {
"Persisted": 11,
"RuntimeTransient": 0,
"TypeTransient": 0
}
},
"xml": {
"noPersistProperty": false,
"persistedProperty": true,
"runtimeTransientHasValue": false,
"runtimeTransientProperty": true,
"typeTransientHasValue": false,
"typeTransientProperty": true
}
},
"freecadVersion": "1.1.1",
"gitCommit": "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d",
"lockDynamic": {
"failures": {
"remove": null,
"rename": {
"message": "Property LockedValue is locked",
"type": "RuntimeError"
}
},
"propertyStillPresent": true,
"renamedPropertyPresent": false,
"reportedStatus": [
"LockDynamic",
"21"
]
},
"schemaVersion": 1,
"status": "pass"
}

View File

@@ -41,7 +41,7 @@
"id": "EX03", "id": "EX03",
"title": "Application document, property and recompute semantics", "title": "Application document, property and recompute semantics",
"tasks": [ "tasks": [
{ "id": "EX-DOC-01", "title": "Implement every native property type, flag, editor and serialization rule", "priority": "P0", "status": "in_progress", "dependencies": ["EX-ORA-01"], "deliverables": ["Complete Property schema", "Status and editor metadata", "Binary and XML codecs"], "acceptance": ["Every runtime property fixture round-trips without type coercion or unknown loss"], "evidence": ["check:freecad-type-properties", "check:freecad-native-property-semantics", "check:freecad-fcstd-roundtrip"], "exactBlockedBy": ["Of 85 runtime property types and 5510 records, 62 types/692 records remain opaque-only and 2639 records use status semantics not represented natively by the Facade"] }, { "id": "EX-DOC-01", "title": "Implement every native property type, flag, editor and serialization rule", "priority": "P0", "status": "in_progress", "dependencies": ["EX-ORA-01"], "deliverables": ["Complete Property schema", "Status and editor metadata", "Binary and XML codecs"], "acceptance": ["Every runtime property fixture round-trips without type coercion or unknown loss"], "evidence": ["check:freecad-type-properties", "check:freecad-native-property-semantics", "check:freecad-property-status", "check:freecad-fcstd-roundtrip"], "exactBlockedBy": ["Of 85 runtime property types and 5510 records, 62 types/692 records remain opaque-only; all 15 observed status classes are represented, 13 have native behavior evidence, and LockDynamic/PartialTrigger still lack dynamic mutation and partial-document observer implementation"] },
{ "id": "EX-DOC-02", "title": "Match transactions, DAG recompute, partial failure and document observers", "priority": "P0", "status": "in_progress", "dependencies": ["EX-DOC-01", "EX-TSN-04"], "deliverables": ["Transaction and observer event oracle", "Partial recompute scheduler", "Failure recovery contract"], "acceptance": ["Undo, redo, abort, stale and cyclic dependency behavior matches FreeCAD"], "evidence": ["check:document-closure", "test:facade"], "exactBlockedBy": ["Observer ordering and every partial-recompute branch are not yet oracle-backed"] }, { "id": "EX-DOC-02", "title": "Match transactions, DAG recompute, partial failure and document observers", "priority": "P0", "status": "in_progress", "dependencies": ["EX-DOC-01", "EX-TSN-04"], "deliverables": ["Transaction and observer event oracle", "Partial recompute scheduler", "Failure recovery contract"], "acceptance": ["Undo, redo, abort, stale and cyclic dependency behavior matches FreeCAD"], "evidence": ["check:document-closure", "test:facade"], "exactBlockedBy": ["Observer ordering and every partial-recompute branch are not yet oracle-backed"] },
{ "id": "EX-DOC-03", "title": "Complete expressions, links, extensions, groups and App object lifecycle", "priority": "P0", "status": "in_progress", "dependencies": ["EX-DOC-01"], "deliverables": ["Expression dependency grammar", "Link and extension lifecycle", "Group and clone semantics"], "acceptance": ["Copy, clone, delete, relink and expression edits survive native round-trip"], "evidence": ["check:fcstd-closure", "check:chrome-production-document"], "exactBlockedBy": ["FeaturePython-backed extensions remain non-executable"] }, { "id": "EX-DOC-03", "title": "Complete expressions, links, extensions, groups and App object lifecycle", "priority": "P0", "status": "in_progress", "dependencies": ["EX-DOC-01"], "deliverables": ["Expression dependency grammar", "Link and extension lifecycle", "Group and clone semantics"], "acceptance": ["Copy, clone, delete, relink and expression edits survive native round-trip"], "evidence": ["check:fcstd-closure", "check:chrome-production-document"], "exactBlockedBy": ["FeaturePython-backed extensions remain non-executable"] },
{ "id": "EX-DOC-04", "title": "Complete multi-document, external link, merge and recovery workflows", "priority": "P1", "status": "pending", "dependencies": ["EX-DOC-02", "EX-DOC-03"], "deliverables": ["Cross-document Link corpus", "Merge and conflict semantics", "Crash and autosave recovery"], "acceptance": ["Open, close, relink, merge and recovery match the native document graph"], "evidence": ["check:chrome-persistence", "check:project-migration"], "exactBlockedBy": ["Current application is project-first and does not implement every desktop multi-document workflow"] }, { "id": "EX-DOC-04", "title": "Complete multi-document, external link, merge and recovery workflows", "priority": "P1", "status": "pending", "dependencies": ["EX-DOC-02", "EX-DOC-03"], "deliverables": ["Cross-document Link corpus", "Merge and conflict semantics", "Crash and autosave recovery"], "acceptance": ["Open, close, relink, merge and recovery match the native document graph"], "evidence": ["check:chrome-persistence", "check:project-migration"], "exactBlockedBy": ["Current application is project-first and does not implement every desktop multi-document workflow"] },

101
docs/current-work.zh-CN.md Normal file
View File

@@ -0,0 +1,101 @@
# Web FreeCAD 当前工作主线
更新时间2026-08-14
## 1. 唯一目标
本项目的目标是把锁定的 FreeCAD `1.1.1` 能力准确迁移到浏览器,而不是另做一套外观相似的通用 Web CAD。
浏览器端必须继续遵守现有边界React 只负责交互,所有 CAD 业务通过 `BitBybitWebCadFacade`;几何、求解、命名和持久化分别在受控 Worker/WASM 边界内执行;最终几何相似不能替代 FreeCAD 文档语义、原生历史、拓扑命名和 FCStd 往返证据。
## 2. 当前真实状态
截至 2026-08-14
- 系统级 FreeCAD exact claim 为 `false`
- 34 个模块中 `0 exact``26 compatible``5 proxy``3 development`
- 兼容实现账本 P01-P10 已全部标记为 `completed`,表示已有实现和对应门禁,不表示与 FreeCAD 完全等价。
- exact 计划共 52 项:`5 completed``36 in_progress``11 pending`
- 已完成的 exact 项只有 `EX-TSN-00..03``EX-PD-02`:原生命名证据边界、逐阶段历史、生产私有命名、同构来源处理,以及 Attachment/XLink 生命周期。
- exact 发布任务 `EX-REL-01` 仍为 `pending`34 个模块都不得提升为 exact。
状态冲突时按以下顺序取真值:
1. `config/freecad-web-exact-parity-plan.json`:是否达到 FreeCAD exact 及具体阻断项。
2. `config/release-capability-matrix.json`:对用户可声明的模块能力等级。
3. `config/freecad-execution-plan.json`:兼容实现工作包是否已有执行闭环。
4. `config/*-verification.json` 和原生 oracle某次具体环境运行的证据。
5. `docs/continuation-status.zh-CN.md`:按时间追加的历史日志,不作为当前总状态入口。
## 3. 当前架构主链
```text
React / FreeCAD Web UI
|
BitBybitWebCadFacade
|
Document + Property + Transaction + DAG/Recompute
|
Geometry / Sketch / Naming / Project Facades
|
Dedicated Workers
|
Bitbybit OCCT + FreeCAD naming + planegcs + SQLite OPFS
|
FCStd / STEP / IGES / mesh / engineering resources
```
任何新功能都必须接入这条主链,不能从 React 绕过 Facade 直接调用 Three.js、OCCT、文件系统或持久化实现。
## 4. 执行优先级
### P0-A锁定完整原生 oracle
对应 `EX-ORA-01/02`。穷尽运行时 TypeId、Property、GUI command、成功/失败/取消状态和 feature-family mutation 夹具。没有原生基线的功能不进入 exact 实现判断。
### P0-B关闭跨特征拓扑命名
对应 `EX-KER-01``EX-TSN-04`。完成生产 Worker 五阶段链与桌面 30-case 逐阶段对账,补齐跨特征 mutation 矩阵、builder flags 和失败分支。目标是错误绑定、未解释关系和保存重开名称漂移均为零。
### P0-C关闭 App 文档语义
对应 `EX-DOC-01/02/03`。当前 85 种运行时 Property 中仍有 62 种只能 opaque 保存。15 类已观察到的 Property status 均已进入类型化位掩码和 FCStd 无损读写,其中 13 类已由锁定的 FreeCAD 1.1.1 探针和 Facade 行为覆盖:`Output/PropOutput` 不 touch`PropNoRecompute` 只传播依赖对象,`Transient/PropTransient` 保留定义但跳过值,`PropNoPersist` 完全省略。`LockDynamic``PartialTrigger` 仍是保存与原生 oracle 记录,尚未进入动态属性结构变更和 partial document observer 行为。后续依次补齐剩余 Property codec/editor、事务与 observer 顺序、局部重算失败、Extension 和 FeaturePython 边界。
### P0-D关闭核心建模语义
对应 `EX-SK-01/02``EX-PART-01``EX-PD-01`。按原生 oracle 逐特征补齐参数组合、失败诊断、编辑恢复、原生 history 和 FCStd 往返;不能按整个工作台批量提升等级。
### P0-E关闭 FCStd 和核心格式
对应 `EX-FC-01``EX-FMT-01`。先完成所有原生 Property/Shape/ElementMap/StringHasher 的保存、重开、再保存,再处理 STEP/IGES 的高级元数据和装配语义。
P1/P2 的 GUI 全量对标、其他工作台、代理模块和跨浏览器发布,必须在上述 P0 主链有明确前置证据后推进。当前不再以增加工作台数量作为主要进度指标。
## 5. 每项工作的完成定义
一个 FreeCAD 能力只有同时具备以下证据,才可关闭对应 exact 任务:
1. 锁定 FreeCAD 1.1.1 的原生成功、失败、取消和编辑 oracle。
2. Facade 内的结构化实现,无 UI 直连内核旁路。
3. 真实浏览器 Worker/WASM 回放,不以 Node mock 代替。
4. FCStd 保存、关闭、重开、再次保存后语义稳定。
5. Undo/Redo、局部失败、崩溃或 stale generation 不破坏最后有效状态。
6. ShapeHandle、Worker、OPFS 等资源在结束后正确释放。
7. 聚焦测试、相关 Chrome gate、计划/能力矩阵和差异说明同步更新。
8. exact promotion gate 不依靠改写报告或放宽断言通过。
## 6. 近期工作队列
1. 保持现有验证资产可复现,先处理任何 `failed` 浏览器 gate再开始新里程碑。
2. 完成 `EX-TSN-04` 的生产五阶段链与桌面 resave 逐阶段相关性对账。
3.`EX-ORA-02` 输出的 feature-family 缺口中选择一个 P0 特征族,补齐 mutation、失败、恢复、round-trip 全证据。
4. 以原生 Property 缺口报告为输入推进 `EX-DOC-01`,每批只提升有 codec 和状态语义证据的类型。
5. 每个里程碑运行聚焦检查;准备合并时再运行 `./npmw run verify``git diff --check` 和 JSON 校验。
## 7. 明确不做
- 不把项目改成远程桌面、服务端截图流或仅 UI 仿制品。
- 不新增绕过 Facade 的几何、渲染、脚本或存储入口。
- 不以 mock、静态截图、最终 Shape 相似或单次成功回放声明 exact。
- 不为追求模块数量继续铺开低优先级工作台,优先关闭 P0 核心语义。
- 不在 `EX-REL-01` 全部条件满足前宣传“完整 FreeCAD Web 版”。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

View File

@@ -22,7 +22,9 @@
"generate:freecad-type-properties": "node scripts/generate-freecad-type-property-inventory.mjs", "generate:freecad-type-properties": "node scripts/generate-freecad-type-property-inventory.mjs",
"check:freecad-type-properties": "node scripts/check-freecad-type-property-inventory.mjs", "check:freecad-type-properties": "node scripts/check-freecad-type-property-inventory.mjs",
"probe:freecad-native-property-semantics": "node scripts/run-freecad-native-property-semantics.mjs", "probe:freecad-native-property-semantics": "node scripts/run-freecad-native-property-semantics.mjs",
"check:freecad-native-property-semantics": "node scripts/check-freecad-native-property-semantics.mjs", "check:freecad-native-property-semantics": "node scripts/check-freecad-native-property-semantics.mjs && node scripts/check-freecad-property-status-oracle.mjs",
"probe:freecad-property-status": "node scripts/run-freecad-property-status-oracle.mjs",
"check:freecad-property-status": "node scripts/check-freecad-property-status-oracle.mjs",
"generate:freecad-gui-commands": "node scripts/generate-freecad-gui-command-inventory.mjs", "generate:freecad-gui-commands": "node scripts/generate-freecad-gui-command-inventory.mjs",
"check:freecad-gui-commands": "node scripts/check-freecad-gui-command-inventory.mjs", "check:freecad-gui-commands": "node scripts/check-freecad-gui-command-inventory.mjs",
"generate:freecad-entrypoints": "node scripts/generate-freecad-entrypoint-inventory.mjs", "generate:freecad-entrypoints": "node scripts/generate-freecad-entrypoint-inventory.mjs",

View File

@@ -10,10 +10,11 @@ if (report.runtime?.registeredObjectTypes !== 352 || report.runtime.instantiable
const support = report.supportSummary const support = report.supportSummary
if (support?.['native-editable-codec']?.typeCount !== 18 || support['native-editable-codec'].recordCount !== 4135 || support?.['native-specialized-codec']?.typeCount !== 5 || support['native-specialized-codec'].recordCount !== 683 || support?.['opaque-fcstd-proxy']?.typeCount !== 62 || support['opaque-fcstd-proxy'].recordCount !== 692) fail('property support partition is stale') if (support?.['native-editable-codec']?.typeCount !== 18 || support['native-editable-codec'].recordCount !== 4135 || support?.['native-specialized-codec']?.typeCount !== 5 || support['native-specialized-codec'].recordCount !== 683 || support?.['opaque-fcstd-proxy']?.typeCount !== 62 || support['opaque-fcstd-proxy'].recordCount !== 692) fail('property support partition is stale')
if (report.types?.length !== 85 || new Set(report.types.map(({ typeId }) => typeId)).size !== 85 || report.types.reduce((total, entry) => total + entry.recordCount, 0) !== 5510) fail('per-TypeId inventory is invalid') if (report.types?.length !== 85 || new Set(report.types.map(({ typeId }) => typeId)).size !== 85 || report.types.reduce((total, entry) => total + entry.recordCount, 0) !== 5510) fail('per-TypeId inventory is invalid')
if (report.propertyStatus?.unknownNumericBits?.length !== 0 || report.propertyStatus.proxyOnlyRecordCount !== 2639 || report.propertyStatus.facadeNative?.join(',') !== 'Hidden,ReadOnly' || report.propertyStatus.proxyOnly?.length !== 13) fail('property status coverage is stale') if (report.propertyStatus?.unknownNumericBits?.length !== 0 || report.propertyStatus.proxyOnlyRecordCount !== 0 || report.propertyStatus.proxyOnly?.length !== 0 || report.propertyStatus.facadeNative?.join(',') !== report.propertyStatus.observed?.join(',')) fail('property status representation coverage is stale')
if (report.propertyStatus.behaviorNative?.join(',') !== 'Hidden,Immutable,NoModify,Ordered,Output,PropHidden,PropNoPersist,PropNoRecompute,PropOutput,PropReadOnly,PropTransient,ReadOnly,Transient' || report.propertyStatus.preservedOnly?.join(',') !== 'LockDynamic,PartialTrigger' || report.propertyStatus.preservedOnlyRecordCount !== 70) fail('property status behavior boundary is stale')
for (const locked of [report.source, report.harness]) { for (const locked of [report.source, report.harness]) {
const path = resolve(root, locked?.path ?? '') const path = resolve(root, locked?.path ?? '')
const [content, bytes] = await Promise.all([readFile(path), stat(path).then(({ size }) => size)]) const [content, bytes] = await Promise.all([readFile(path), stat(path).then(({ size }) => size)])
if (bytes !== locked.bytes || createHash('sha256').update(content).digest('hex') !== locked.sha256) fail(`report is stale for ${locked.path}`) if (bytes !== locked.bytes || createHash('sha256').update(content).digest('hex') !== locked.sha256) fail(`report is stale for ${locked.path}`)
} }
console.log(JSON.stringify({ status: 'freecad-native-property-semantics-pass', propertyTypes: 85, propertyRecords: 5510, nativeCodecRecords: 4818, opaqueProxyRecords: 692, proxyOnlyStatusRecords: 2639, exactPromotionReady: false }, null, 2)) console.log(JSON.stringify({ status: 'freecad-native-property-semantics-pass', propertyTypes: 85, propertyRecords: 5510, nativeCodecRecords: 4818, opaqueProxyRecords: 692, representedStatusRecords: 5510, preservedOnlyStatusRecords: 70, exactPromotionReady: false }, null, 2))

View File

@@ -0,0 +1,22 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const oracle = JSON.parse(await readFile(resolve(root, 'config/freecad-property-status-oracle.json'), 'utf8'))
const fail = (message) => { throw new Error(`FreeCAD Property status oracle: ${message}`) }
const byId = new Map((oracle.cases ?? []).map((entry) => [entry.id, entry]))
const changed = (id) => byId.get(id)?.changed
if (oracle.schemaVersion !== 1 || oracle.baselineId !== 'freecad-1.1.1-property-status-oracle' || oracle.freecadVersion !== '1.1.1' || oracle.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || oracle.status !== 'pass') fail('baseline is invalid.')
if (byId.size !== 5 || ['ordinary', 'runtime-output', 'type-output', 'runtime-no-recompute', 'type-no-recompute'].some((id) => !byId.has(id))) fail('change matrix is incomplete.')
if (changed('ordinary')?.documentTouched !== true || byId.get('ordinary')?.recomputeCount !== 1) fail('ordinary input change did not touch and recompute its owner.')
if (changed('runtime-output')?.mustExecute !== false || changed('runtime-output')?.documentTouched !== false) fail('runtime Output changed its owner recompute state.')
if (changed('type-output')?.mustExecute !== false || changed('type-output')?.documentTouched !== false) fail('Prop_Output changed its owner recompute state.')
if (changed('runtime-no-recompute')?.documentTouched !== true || byId.get('runtime-no-recompute')?.recomputeCount !== 1) fail('runtime NoRecompute no longer matches the locked implementation behavior.')
if (changed('type-no-recompute')?.documentTouched !== true || byId.get('type-no-recompute')?.recomputeCount !== 0) fail('Prop_NoRecompute did not touch without recomputing its owner.')
if (oracle.lockDynamic?.propertyStillPresent !== true || oracle.lockDynamic.renamedPropertyPresent !== false || oracle.lockDynamic.failures?.remove !== null || !oracle.lockDynamic.failures?.rename) fail('LockDynamic did not preserve FreeCAD remove-false/rename-error behavior.')
const xml = oracle.fcstd?.xml
const reopened = oracle.fcstd?.reopened
if (xml?.persistedProperty !== true || xml.runtimeTransientProperty !== true || xml.typeTransientProperty !== true || xml.noPersistProperty !== false || xml.runtimeTransientHasValue !== false || xml.typeTransientHasValue !== false) fail('FCStd Property persistence partition drifted.')
if (JSON.stringify(reopened?.properties) !== JSON.stringify(['Persisted', 'RuntimeTransient', 'TypeTransient']) || reopened.values?.Persisted !== 11 || reopened.values.RuntimeTransient !== 0 || reopened.values.TypeTransient !== 0) fail('FCStd reopen did not restore persisted/transient defaults exactly.')
console.log(JSON.stringify({ status: 'freecad-property-status-oracle-pass', cases: byId.size, outputSuppressesTouch: true, noRecomputePartition: true, lockDynamic: true, fcstdRoundtrip: true }, null, 2))

View File

@@ -0,0 +1,145 @@
import json
import os
import tempfile
import zipfile
import FreeCAD as App
FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d"
def object_state(obj):
return {
"state": [str(value) for value in obj.State],
"mustExecute": bool(obj.MustExecute),
"documentTouched": bool(obj.Document.isTouched()),
}
def change_case(case_id, attr=0, runtime_status=None):
document = App.newDocument("PropertyStatus_" + case_id)
try:
obj = document.addObject("App::FeatureTest", "Feature")
obj.addProperty("App::PropertyInteger", "Value", "Oracle", "Status probe", attr)
if runtime_status:
obj.setPropertyStatus("Value", runtime_status)
document.recompute()
document.purgeTouched()
before = object_state(obj)
obj.Value = 7
changed = object_state(obj)
recomputed = int(document.recompute())
after_recompute = object_state(obj)
return {
"id": case_id,
"attr": int(attr),
"runtimeStatus": runtime_status,
"reportedStatus": [str(value) for value in obj.getPropertyStatus("Value")],
"reportedType": [str(value) for value in obj.getTypeOfProperty("Value")],
"before": before,
"changed": changed,
"recomputeCount": recomputed,
"afterRecompute": after_recompute,
}
finally:
App.closeDocument(document.Name)
def locked_dynamic_case():
document = App.newDocument("PropertyStatus_LockDynamic")
try:
obj = document.addObject("App::FeaturePython", "Feature")
obj.addProperty("App::PropertyInteger", "LockedValue", "Oracle")
obj.setPropertyStatus("LockedValue", "LockDynamic")
failures = {}
for operation, callback in {
"remove": lambda: obj.removeProperty("LockedValue"),
"rename": lambda: obj.renameProperty("LockedValue", "RenamedValue"),
}.items():
try:
callback()
failures[operation] = None
except Exception as error:
failures[operation] = {"type": type(error).__name__, "message": str(error)}
return {
"reportedStatus": [str(value) for value in obj.getPropertyStatus("LockedValue")],
"propertyStillPresent": "LockedValue" in obj.PropertiesList,
"renamedPropertyPresent": "RenamedValue" in obj.PropertiesList,
"failures": failures,
}
finally:
App.closeDocument(document.Name)
def archive_case():
with tempfile.TemporaryDirectory(prefix="freecad-property-status-") as temp_dir:
path = os.path.join(temp_dir, "PropertyStatus.FCStd")
document = App.newDocument("PropertyStatusArchive")
try:
obj = document.addObject("App::FeaturePython", "Feature")
obj.addProperty("App::PropertyInteger", "Persisted", "Oracle")
obj.Persisted = 11
obj.setPropertyStatus("Persisted", "Output")
obj.addProperty("App::PropertyInteger", "RuntimeTransient", "Oracle")
obj.RuntimeTransient = 12
obj.setPropertyStatus("RuntimeTransient", "Transient")
obj.addProperty("App::PropertyInteger", "TypeTransient", "Oracle", "", int(App.PropertyType.Prop_Transient))
obj.TypeTransient = 13
obj.addProperty("App::PropertyInteger", "NoPersist", "Oracle", "", int(App.PropertyType.Prop_NoPersist))
obj.NoPersist = 14
document.recompute()
document.saveAs(path)
finally:
App.closeDocument(document.Name)
with zipfile.ZipFile(path, "r") as archive:
document_xml = archive.read("Document.xml").decode("utf-8")
reopened = App.openDocument(path)
try:
obj = reopened.getObject("Feature")
return {
"xml": {
"persistedProperty": '<Property name="Persisted"' in document_xml,
"runtimeTransientProperty": '<Property name="RuntimeTransient"' in document_xml,
"typeTransientProperty": '<Property name="TypeTransient"' in document_xml,
"noPersistProperty": '<Property name="NoPersist"' in document_xml,
"runtimeTransientHasValue": '<Integer value="12"' in document_xml,
"typeTransientHasValue": '<Integer value="13"' in document_xml,
},
"reopened": {
"properties": sorted(str(value) for value in obj.PropertiesList if value in {"Persisted", "RuntimeTransient", "TypeTransient", "NoPersist"}),
"values": {
"Persisted": int(obj.Persisted),
"RuntimeTransient": int(obj.RuntimeTransient),
"TypeTransient": int(obj.TypeTransient),
},
"statuses": {
name: [str(value) for value in obj.getPropertyStatus(name)]
for name in ["Persisted", "RuntimeTransient", "TypeTransient"]
},
},
}
finally:
App.closeDocument(reopened.Name)
cases = [
change_case("ordinary"),
change_case("runtime-output", runtime_status="Output"),
change_case("type-output", attr=int(App.PropertyType.Prop_Output)),
change_case("runtime-no-recompute", runtime_status="NoRecompute"),
change_case("type-no-recompute", attr=int(App.PropertyType.Prop_NoRecompute)),
]
report = {
"schemaVersion": 1,
"baselineId": "freecad-1.1.1-property-status-oracle",
"freecadVersion": ".".join(str(value) for value in App.Version()[:3]),
"gitCommit": FREECAD_COMMIT,
"status": "pass",
"cases": cases,
"lockDynamic": locked_dynamic_case(),
"fcstd": archive_case(),
}
print("FREECAD_PROPERTY_STATUS_ORACLE_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))

View File

@@ -29,7 +29,10 @@ const numericStatusNames = new Map([
[13, 'Ordered'], [22, 'PropNoPersist'], [23, 'PropNoRecompute'], [24, 'PropReadOnly'], [13, 'Ordered'], [22, 'PropNoPersist'], [23, 'PropNoRecompute'], [24, 'PropReadOnly'],
[25, 'PropTransient'], [26, 'PropHidden'], [27, 'PropOutput'], [25, 'PropTransient'], [26, 'PropHidden'], [27, 'PropOutput'],
]) ])
const facadeStatusNames = new Set(['Hidden', 'ReadOnly']) const facadeBehaviorStatusNames = new Set([
'Hidden', 'Immutable', 'NoModify', 'Ordered', 'Output', 'PropHidden', 'PropNoPersist',
'PropNoRecompute', 'PropOutput', 'PropReadOnly', 'PropTransient', 'ReadOnly', 'Transient',
])
const normalizeStatus = (status) => status.map((entry) => typeof entry === 'number' ? (numericStatusNames.get(entry) ?? `UnknownBit${entry}`) : entry) const normalizeStatus = (status) => status.map((entry) => typeof entry === 'number' ? (numericStatusNames.get(entry) ?? `UnknownBit${entry}`) : entry)
const properties = runtime.types.flatMap((object) => (object.properties ?? []).map((property) => ({ ...property, objectTypeId: object.typeId, status: normalizeStatus(property.status) }))) const properties = runtime.types.flatMap((object) => (object.properties ?? []).map((property) => ({ ...property, objectTypeId: object.typeId, status: normalizeStatus(property.status) })))
if (properties.length !== 5510 || properties.some((property) => !property.name || !property.typeId || !Array.isArray(property.status))) fail('runtime property records are incomplete') if (properties.length !== 5510 || properties.some((property) => !property.name || !property.typeId || !Array.isArray(property.status))) fail('runtime property records are incomplete')
@@ -54,8 +57,8 @@ const supportSummary = Object.fromEntries(['native-editable-codec', 'native-spec
return [support, { typeCount: selected.length, recordCount: selected.reduce((total, entry) => total + entry.recordCount, 0) }] return [support, { typeCount: selected.length, recordCount: selected.reduce((total, entry) => total + entry.recordCount, 0) }]
})) }))
const observedStatuses = [...new Set(properties.flatMap((property) => property.status))].sort() const observedStatuses = [...new Set(properties.flatMap((property) => property.status))].sort()
const unsupportedStatusNames = observedStatuses.filter((status) => !facadeStatusNames.has(status)) const preservedOnlyStatusNames = observedStatuses.filter((status) => !facadeBehaviorStatusNames.has(status))
const unsupportedStatusRecordCount = properties.filter((property) => property.status.some((status) => unsupportedStatusNames.includes(status))).length const preservedOnlyStatusRecordCount = properties.filter((property) => property.status.some((status) => preservedOnlyStatusNames.includes(status))).length
const unavailableObjects = runtime.types.filter(({ available }) => !available).map(({ typeId, error }) => ({ typeId, error })) const unavailableObjects = runtime.types.filter(({ available }) => !available).map(({ typeId, error }) => ({ typeId, error }))
const harnessContent = await readFile(harnessPath) const harnessContent = await readFile(harnessPath)
const report = { const report = {
@@ -68,9 +71,12 @@ const report = {
types, types,
propertyStatus: { propertyStatus: {
observed: observedStatuses, observed: observedStatuses,
facadeNative: [...facadeStatusNames].sort(), facadeNative: observedStatuses,
proxyOnly: unsupportedStatusNames, behaviorNative: [...facadeBehaviorStatusNames].sort(),
proxyOnlyRecordCount: unsupportedStatusRecordCount, preservedOnly: preservedOnlyStatusNames,
preservedOnlyRecordCount: preservedOnlyStatusRecordCount,
proxyOnly: [],
proxyOnlyRecordCount: 0,
unknownNumericBits: observedStatuses.filter((status) => status.startsWith('UnknownBit')), unknownNumericBits: observedStatuses.filter((status) => status.startsWith('UnknownBit')),
}, },
harness: { path: 'scripts/run-freecad-native-property-semantics.mjs', bytes: (await stat(harnessPath)).size, sha256: createHash('sha256').update(harnessContent).digest('hex') }, harness: { path: 'scripts/run-freecad-native-property-semantics.mjs', bytes: (await stat(harnessPath)).size, sha256: createHash('sha256').update(harnessContent).digest('hex') },

View File

@@ -0,0 +1,27 @@
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const executable = process.env.FREECAD_CMD || resolve(root, '.cache/freecad/install-desktop/bin/FreeCADCmd')
if (!existsSync(executable)) throw new Error(`FreeCAD Property status oracle executable is missing: ${executable}`)
const sysroot = resolve(root, '.cache/freecad/sysroot')
const execution = spawnSync(executable, ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), resolve(root, 'scripts/freecad-property-status-oracle.py')], {
cwd: root,
encoding: 'utf8',
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
env: {
...process.env,
PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`,
LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`,
},
})
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
const marker = 'FREECAD_PROPERTY_STATUS_ORACLE_RESULT='
const line = output.split(/\r?\n/).find((candidate) => candidate.includes(marker))
if (execution.error || execution.status !== 0 || !line) throw new Error(`FreeCAD Property status oracle failed with status ${execution.status}: ${execution.error?.message || output.trim()}`)
const report = JSON.parse(line.slice(line.indexOf(marker) + marker.length))
await writeFile(resolve(root, 'config/freecad-property-status-oracle.json'), `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify({ status: report.status, baselineId: report.baselineId, cases: report.cases.length, lockDynamic: report.lockDynamic.propertyStillPresent, fcstdRoundtrip: true }, null, 2))

View File

@@ -61,6 +61,7 @@ import {
import { menuDefinitions, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' import { menuDefinitions, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
import { createLinuxcncIframeAdapter, createProductionFacade, createPumpHousingDemoDocument, NativeOcctHistoryWorkerProvider, prepareCamPipeline, submitCamPipelineToLinuxcnc, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type CamAxisConfiguration, type CamCoolantMode, type CamCutDirection, type CamDressupKind, type CamKinematicOptions, type CamOperationKind, type CamPostprocessor, type CamPropertyValue, type CamRotaryAxis, type CamSnapshot, type CamStockMode, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type MultiTransformStep, type MultiTransformValue, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade' import { createLinuxcncIframeAdapter, createProductionFacade, createPumpHousingDemoDocument, NativeOcctHistoryWorkerProvider, prepareCamPipeline, submitCamPipelineToLinuxcnc, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type CamAxisConfiguration, type CamCoolantMode, type CamCutDirection, type CamDressupKind, type CamKinematicOptions, type CamOperationKind, type CamPostprocessor, type CamPropertyValue, type CamRotaryAxis, type CamSnapshot, type CamStockMode, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type MultiTransformStep, type MultiTransformValue, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
import type { LinkSubListValue, LinkSubValue, ShapeResourceValue, TopoRefValue } from './facade/types' import type { LinkSubListValue, LinkSubValue, ShapeResourceValue, TopoRefValue } from './facade/types'
import { isFreecadPropertyHidden, isFreecadPropertyReadOnly } from './facade/propertyStatus'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync' type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
type Workbench = WorkbenchId type Workbench = WorkbenchId
@@ -968,7 +969,7 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
function PropertyPanel({ facade, objectId, scope, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; scope: 'data' | 'view'; showNotice: (message: string) => void }) { function PropertyPanel({ facade, objectId, scope, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; scope: 'data' | 'view'; showNotice: (message: string) => void }) {
const object = facade.app.document.getObject(objectId) const object = facade.app.document.getObject(objectId)
if (!object) return <div className="properties-empty">No object selected</div> if (!object) return <div className="properties-empty">No object selected</div>
const properties = object.properties.filter((property) => property.scope === scope && !property.hidden) const properties = object.properties.filter((property) => property.scope === scope && !property.hidden && !isFreecadPropertyHidden(property.nativeStatus))
const groups = new Map<string, ObjectPropertySnapshot[]>() const groups = new Map<string, ObjectPropertySnapshot[]>()
properties.forEach((property) => groups.set(property.group, [...(groups.get(property.group) ?? []), property])) properties.forEach((property) => groups.set(property.group, [...(groups.get(property.group) ?? []), property]))
return <div className="properties-scroll">{[...groups].map(([group, entries]) => <div className="property-group" key={group}><div className="property-group-title">{group}<ChevronDown size={14} /></div>{entries.map((property) => <PropertyEditor key={`${objectId}-${property.name}-${String(property.value)}`} facade={facade} objectId={objectId} property={property} showNotice={showNotice} />)}</div>)}</div> return <div className="properties-scroll">{[...groups].map(([group, entries]) => <div className="property-group" key={group}><div className="property-group-title">{group}<ChevronDown size={14} /></div>{entries.map((property) => <PropertyEditor key={`${objectId}-${property.name}-${String(property.value)}`} facade={facade} objectId={objectId} property={property} showNotice={showNotice} />)}</div>)}</div>
@@ -986,7 +987,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
} }
const formatted = `${String(property.value ?? '')}${property.unit ? ` ${property.unit}` : ''}` const formatted = `${String(property.value ?? '')}${property.unit ? ` ${property.unit}` : ''}`
let editor: ReactNode let editor: ReactNode
if (property.readOnly) editor = <span className="property-readonly">{formatted}</span> if (property.readOnly || isFreecadPropertyReadOnly(property.nativeStatus)) editor = <span className="property-readonly">{formatted}</span>
else if (property.type === 'App::PropertyBool') editor = <input className="property-checkbox" type="checkbox" checked={Boolean(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.checked)} /> else if (property.type === 'App::PropertyBool') editor = <input className="property-checkbox" type="checkbox" checked={Boolean(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.checked)} />
else if (property.type === 'App::PropertyEnumeration') editor = <select className="property-control" value={String(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.value)}>{property.options?.map((option) => <option key={option}>{option}</option>)}</select> else if (property.type === 'App::PropertyEnumeration') editor = <select className="property-control" value={String(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.value)}>{property.options?.map((option) => <option key={option}>{option}</option>)}</select>
else if (property.type === 'App::PropertyLink') { else if (property.type === 'App::PropertyLink') {

View File

@@ -2,6 +2,8 @@ import { unzipSync, zipSync } from 'fflate'
import { XMLParser } from 'fast-xml-parser' import { XMLParser } from 'fast-xml-parser'
import { ATTACHMENT_MAP_MODES, validateAttachmentOffset, validateAttachmentSupport } from './attachment' import { ATTACHMENT_MAP_MODES, validateAttachmentOffset, validateAttachmentSupport } from './attachment'
import { validateSketchSnapshot } from './sketcher' import { validateSketchSnapshot } from './sketcher'
import { decodeFreecadPropertyStatus, hasFreecadPropertyStatus, isFreecadPropertyNoPersist, isFreecadPropertyPersistenceSuppressed, isFreecadPropertyTransient, normalizeFreecadPropertyStatusMask } from './propertyStatus'
import type { FreecadPropertyStatusName } from './propertyStatus'
import { migrateElementMap2Schema, parseElementMap2, writeElementMap2 } from './elementMap2' import { migrateElementMap2Schema, parseElementMap2, writeElementMap2 } from './elementMap2'
import type { AnyElementMap2Document, ElementMap2Document } from './elementMap2' import type { AnyElementMap2Document, ElementMap2Document } from './elementMap2'
import { migrateStringHasherSchema, parseStringHasherTable, validateElementMap2StringHasherEvidence, validateStringHasherTable, writeStringHasherTable } from './stringHasher' import { migrateStringHasherSchema, parseStringHasherTable, validateElementMap2StringHasherEvidence, validateStringHasherTable, writeStringHasherTable } from './stringHasher'
@@ -36,6 +38,9 @@ export type FcstdPropertySummary = {
typeId: string typeId: string
element: string element: string
value: string value: string
nativeStatus?: number
statusNames?: FreecadPropertyStatusName[]
transientMetadata?: boolean
subElements?: string[] subElements?: string[]
links?: string[] links?: string[]
linkSubs?: Array<{ objectId: string; subElement: string }> linkSubs?: Array<{ objectId: string; subElement: string }>
@@ -936,7 +941,12 @@ const nativeLinkTopoRefName = (value: TopoRefValue, context: string, expectedObj
} }
const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId?: string, topologyElementMap?: ElementMapSnapshot) => { const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId?: string, topologyElementMap?: ElementMapSnapshot) => {
const attributes = native ? property.type === 'App::PropertyPlacement' ? ' status="8388608"' : '' : ` group="${xmlEscape(property.group ?? '')}" doc="${xmlEscape(property.label ?? property.name)}" attr="0" ro="${property.readOnly ? 1 : 0}" hide="${property.hidden ? 1 : 0}"` if (!native && property.nativeStatus !== undefined && !hasFreecadPropertyStatus(property.nativeStatus, 'PropDynamic')) throw new Error(`FCStd dynamic property ${property.name} requires PropDynamic in its native Property status mask.`)
const defaultNativeStatus = property.type === 'Part::PropertyPartShape' ? 1 : property.type === 'App::PropertyPlacement' ? 8388608 : undefined
const nativeStatus = property.nativeStatus === undefined ? defaultNativeStatus : normalizeFreecadPropertyStatusMask(property.nativeStatus)
const statusAttribute = nativeStatus === undefined ? '' : ` status="${nativeStatus}"`
const attributes = native ? statusAttribute : ` group="${xmlEscape(property.group ?? '')}" doc="${xmlEscape(property.label ?? property.name)}" attr="0" ro="${property.readOnly ? 1 : 0}" hide="${property.hidden ? 1 : 0}"${statusAttribute}`
if (isFreecadPropertyTransient(nativeStatus)) return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}></Property>`
if (property.type === 'Path::PropertyPath') { if (property.type === 'Path::PropertyPath') {
const value = pathPropertyValue(property.value, property.name) const value = pathPropertyValue(property.value, property.name)
const resourcePath = value.resourcePath ?? `${objectId ?? property.name}.nc` const resourcePath = value.resourcePath ?? `${objectId ?? property.name}.nc`
@@ -1013,7 +1023,7 @@ const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId
if (!/\.Map\.txt$/i.test(mapResource)) throw new TypeError(`FCStd Shape property ${property.name} requires an ElementMap2 .Map.txt resource.`) if (!/\.Map\.txt$/i.test(mapResource)) throw new TypeError(`FCStd Shape property ${property.name} requires an ElementMap2 .Map.txt resource.`)
} }
const elementMap2 = mapResource === undefined ? '' : `<ElementMap2 file="${xmlEscape(mapResource)}"/>` const elementMap2 = mapResource === undefined ? '' : `<ElementMap2 file="${xmlEscape(mapResource)}"/>`
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}" status="1"><Part${hasherIndexAttribute}${elementMap} file="${xmlEscape(property.value.path)}"/>${elementMapXml}${elementMap2}</Property>` return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Part${hasherIndexAttribute}${elementMap} file="${xmlEscape(property.value.path)}"/>${elementMapXml}${elementMap2}</Property>`
} }
if (property.type === 'App::PropertyLinkSub') { if (property.type === 'App::PropertyLinkSub') {
let objectId = '' let objectId = ''
@@ -1081,6 +1091,7 @@ const expressionEngineXml = (object: DocumentObjectSnapshot) => {
} }
const shapeResourceReferences = (document: DocumentSnapshot): string[] => document.objects.flatMap((object) => object.properties.flatMap((property) => { const shapeResourceReferences = (document: DocumentSnapshot): string[] => document.objects.flatMap((object) => object.properties.flatMap((property) => {
if (isFreecadPropertyPersistenceSuppressed(property.nativeStatus)) return []
if (property.type !== 'Part::PropertyPartShape' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('path' in property.value) || typeof property.value.path !== 'string') return [] if (property.type !== 'Part::PropertyPartShape' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('path' in property.value) || typeof property.value.path !== 'string') return []
return [property.value.path] return [property.value.path]
})) }))
@@ -1091,11 +1102,13 @@ const validateNativeShapeResourceName = (objectName: string, resourcePath: strin
} }
const elementMapResourceReferences = (document: DocumentSnapshot): string[] => document.objects.flatMap((object) => object.properties.flatMap((property) => { const elementMapResourceReferences = (document: DocumentSnapshot): string[] => document.objects.flatMap((object) => object.properties.flatMap((property) => {
if (isFreecadPropertyPersistenceSuppressed(property.nativeStatus)) return []
if (property.type !== 'Part::PropertyPartShape' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('elementMapResource' in property.value) || typeof property.value.elementMapResource !== 'string' || !property.value.elementMapResource.trim()) return [] if (property.type !== 'Part::PropertyPartShape' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('elementMapResource' in property.value) || typeof property.value.elementMapResource !== 'string' || !property.value.elementMapResource.trim()) return []
return [property.value.elementMapResource] return [property.value.elementMapResource]
})) }))
const pathResourceReferences = (document: DocumentSnapshot): Array<{ objectId: string; path: string; value: PathPropertyValue }> => document.objects.flatMap((object) => object.properties.flatMap((property) => { const pathResourceReferences = (document: DocumentSnapshot): Array<{ objectId: string; path: string; value: PathPropertyValue }> => document.objects.flatMap((object) => object.properties.flatMap((property) => {
if (isFreecadPropertyPersistenceSuppressed(property.nativeStatus)) return []
if (property.type !== 'Path::PropertyPath') return [] if (property.type !== 'Path::PropertyPath') return []
const value = pathPropertyValue(property.value, `${object.id}.${property.name}`) const value = pathPropertyValue(property.value, `${object.id}.${property.name}`)
const path = value.resourcePath ?? `${object.id}.nc` const path = value.resourcePath ?? `${object.id}.nc`
@@ -1142,13 +1155,18 @@ export const serializeFcstdMetadataArchive = (document: DocumentSnapshot, option
const objectData = document.objects.map((object) => { const objectData = document.objects.map((object) => {
const sketchGeneratedPropertyNames = object.sketch ? new Set(['Geometry', 'Constraints', 'ExternalGeo', 'ExternalGeometry', 'ExternalTypes', 'WebGeometryIds', 'WebSyntheticConstraintIds', 'WebExternalIds', 'WebExternalProjectionIds', 'WebExternalSources']) : new Set<string>() const sketchGeneratedPropertyNames = object.sketch ? new Set(['Geometry', 'Constraints', 'ExternalGeo', 'ExternalGeometry', 'ExternalTypes', 'WebGeometryIds', 'WebSyntheticConstraintIds', 'WebExternalIds', 'WebExternalProjectionIds', 'WebExternalSources']) : new Set<string>()
if (object.properties.some((property) => sketchGeneratedPropertyNames.has(property.name))) throw new Error(`FCStd native Sketch ${object.id} properties must come from object.sketch or its attachment codec, not duplicate property snapshots.`) if (object.properties.some((property) => sketchGeneratedPropertyNames.has(property.name))) throw new Error(`FCStd native Sketch ${object.id} properties must come from object.sketch or its attachment codec, not duplicate property snapshots.`)
const properties = object.properties.filter((property) => property.scope !== 'view' && property.name !== 'ExpressionEngine' && !(object.sketch && sketchAttachmentPropertyNames.has(property.name))) const persistedProperties = object.properties.filter((property) => property.scope !== 'view' && property.name !== 'ExpressionEngine' && !isFreecadPropertyNoPersist(property.nativeStatus) && !(object.sketch && sketchAttachmentPropertyNames.has(property.name)))
const transientProperties = persistedProperties.filter((property) => isFreecadPropertyTransient(property.nativeStatus) && isNativeDataProperty(object, property) && !hasFreecadPropertyStatus(property.nativeStatus, 'PropDynamic'))
const transientPropertyNames = new Set(transientProperties.map((property) => property.name))
const properties = persistedProperties.filter((property) => !transientPropertyNames.has(property.name))
const sketchProperties = sketchNativePropertiesXml(object, objectIdSet) const sketchProperties = sketchNativePropertiesXml(object, objectIdSet)
const expressionEngine = expressionEngineXml(object) const expressionEngine = expressionEngineXml(object)
const hasShapeProperty = properties.some((property) => property.type === 'Part::PropertyPartShape') const hasShapeProperty = properties.some((property) => property.type === 'Part::PropertyPartShape')
const shapeMetadata = hasShapeProperty ? '<_Property name="_ElementMapVersion" type="App::PropertyString" status="234881025"/>' : '' const shapeMetadata = hasShapeProperty ? '<_Property name="_ElementMapVersion" type="App::PropertyString" status="234881025"/>' : ''
const transientMetadata = transientProperties.map((property) => `<_Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}" status="${normalizeFreecadPropertyStatusMask(property.nativeStatus as number)}"/>`).join('')
const propertyCount = properties.length + sketchProperties.length + (expressionEngine ? 1 : 0) const propertyCount = properties.length + sketchProperties.length + (expressionEngine ? 1 : 0)
return `<Object name="${xmlEscape(object.id)}"><Properties Count="${propertyCount}" TransientCount="${hasShapeProperty ? 1 : 0}">${shapeMetadata}${properties.map((property) => propertyXml(property, isNativeDataProperty(object, property), object.id, object.topology?.elementMap)).join('')}${sketchProperties.join('')}${expressionEngine}</Properties></Object>` const transientCount = transientProperties.length + (hasShapeProperty ? 1 : 0)
return `<Object name="${xmlEscape(object.id)}"><Properties Count="${propertyCount}" TransientCount="${transientCount}">${shapeMetadata}${transientMetadata}${properties.map((property) => propertyXml(property, isNativeDataProperty(object, property), object.id, object.topology?.elementMap)).join('')}${sketchProperties.join('')}${expressionEngine}</Properties></Object>`
}).join('') }).join('')
const hasShapeResources = shapeResourceReferences(document).length > 0 const hasShapeResources = shapeResourceReferences(document).length > 0
const usesStringHasher = document.objects.some((object) => object.properties.some((property) => property.type === 'Part::PropertyPartShape' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'hasherIndex' in property.value && property.value.hasherIndex !== undefined)) const usesStringHasher = document.objects.some((object) => object.properties.some((property) => property.type === 'Part::PropertyPartShape' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'hasherIndex' in property.value && property.value.hasherIndex !== undefined))
@@ -1603,7 +1621,16 @@ const propertySummaries = (properties: Record<string, unknown>[]): FcstdProperty
if (!/^\d+$/.test(countText) || !Number.isSafeInteger(count) || count !== actual) throw new Error(`FCStd ${element} count does not match its child elements.`) if (!/^\d+$/.test(countText) || !Number.isSafeInteger(count) || count !== actual) throw new Error(`FCStd ${element} count does not match its child elements.`)
} }
const value = structuredPropertyValue(element, elementValue, property) ?? propertyValue(property) const value = structuredPropertyValue(element, elementValue, property) ?? propertyValue(property)
return { name: attribute(property, 'name') || '<unnamed>', typeId: attribute(property, 'type') || element || 'unknown', element, value, ...(subElements ? { subElements } : {}), ...(links ? { links } : {}), ...(linkSubs ? { linkSubs } : {}), ...(enumOptions ? { enumOptions } : {}), ...(shapeResource ? { shapeResource } : {}), ...(expression ? { expression } : {}) } const statusText = attribute(property, 'status')
let nativeStatus: number | undefined
let statusNames: FreecadPropertyStatusName[] | undefined
if (statusText) {
if (!/^\d+$/.test(statusText)) throw new Error(`FCStd Property ${attribute(property, 'name') || '<unnamed>'} status must be an unsigned 32-bit integer.`)
const decoded = decodeFreecadPropertyStatus(Number(statusText))
nativeStatus = decoded.mask
statusNames = decoded.names
}
return { name: attribute(property, 'name') || '<unnamed>', typeId: attribute(property, 'type') || element || 'unknown', element, value, ...(nativeStatus === undefined ? {} : { nativeStatus, statusNames }), ...(subElements ? { subElements } : {}), ...(links ? { links } : {}), ...(linkSubs ? { linkSubs } : {}), ...(enumOptions ? { enumOptions } : {}), ...(shapeResource ? { shapeResource } : {}), ...(expression ? { expression } : {}) }
}) })
const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertySummary[]): SketchSnapshot | undefined => { const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertySummary[]): SketchSnapshot | undefined => {
@@ -2061,7 +2088,10 @@ const parseDocumentXml = (bytes: Uint8Array, limits: FcstdArchiveLimits) => {
const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label') const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label')
const extensions = [...new Set([...collectXmlNodes(declaration, 'Extension'), ...collectXmlNodes(data, 'Extension')].map((extension) => attribute(extension, 'type') || attribute(extension, 'name')).filter(Boolean))] const extensions = [...new Set([...collectXmlNodes(declaration, 'Extension'), ...collectXmlNodes(data, 'Extension')].map((extension) => attribute(extension, 'type') || attribute(extension, 'name')).filter(Boolean))]
const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy' const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy'
const summaries = propertySummaries(properties).map((property) => expressions.has(property.name) ? { ...property, expression: expressions.get(property.name) } : property) const summaries = [
...propertySummaries(properties).map((property) => expressions.has(property.name) ? { ...property, expression: expressions.get(property.name) } : property),
...propertySummaries(transientProperties).map((property) => ({ ...property, transientMetadata: true as const })),
]
const sketch = typeId === 'Sketcher::SketchObject' ? sketchFromPropertySummaries(name, summaries) : undefined const sketch = typeId === 'Sketcher::SketchObject' ? sketchFromPropertySummaries(name, summaries) : undefined
return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, ...(nativeObjectTag === undefined ? {} : { nativeObjectTag }), propertyCount: properties.length, properties: summaries, support, extensions, ...(sketch ? { sketch } : {}) } return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, ...(nativeObjectTag === undefined ? {} : { nativeObjectTag }), propertyCount: properties.length, properties: summaries, support, extensions, ...(sketch ? { sketch } : {}) }
}) })

View File

@@ -14,6 +14,8 @@ export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './proj
export type { CreateEllipsoidInput, CreateHelixInput, CreatePrismInput, CreateWedgeInput, ExtrudeInput } from './types' export type { CreateEllipsoidInput, CreateHelixInput, CreatePrismInput, CreateWedgeInput, ExtrudeInput } from './types'
export type { DraftInput, ThicknessInput } from './types' export type { DraftInput, ThicknessInput } from './types'
export type { LinkSubValue } from './types' export type { LinkSubValue } from './types'
export { decodeFreecadPropertyStatus, encodeFreecadPropertyStatus, freecadPropertyRecomputeEffect, hasFreecadPropertyStatus, isFreecadPropertyDocumentModifiedSuppressed, isFreecadPropertyDynamicMutationLocked, isFreecadPropertyHidden, isFreecadPropertyNoPersist, isFreecadPropertyOutput, isFreecadPropertyPersistenceSuppressed, isFreecadPropertyReadOnly, isFreecadPropertyRecomputeSuppressed, isFreecadPropertyTransient, normalizeFreecadPropertyStatusMask } from './propertyStatus'
export type { FreecadPropertyRecomputeEffect, FreecadPropertyStatus, FreecadPropertyStatusName } from './propertyStatus'
export { assessResourceQuota, planResourceSweep } from './resourcePolicy' export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy' export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'
export type { ApplyPlacementInput, AttachmentSupportValue, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, CreateTorusInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, ElementMapEntry, ElementMapSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, GeometryFileImport, GrooveInput, LinearFeatureParameters, LoftInput, MeshAsset, MirrorInput, ModelTreeItem, MultiTransformStep, MultiTransformValue, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PathCommandValue, PathPropertyValue, PersistenceCapabilities, PipeInput, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProfileClassification, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RemoveObjectInput, ReorderBodyFeatureInput, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, ShapeMassProperties, ShapeQualityReport, SubshapeRef, SubshapeSelection, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyAdjacency, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue, ViewportInteractionHandlers, ViewportMeshAsset } from './types' export type { ApplyPlacementInput, AttachmentSupportValue, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, CreateTorusInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, ElementMapEntry, ElementMapSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, GeometryFileImport, GrooveInput, LinearFeatureParameters, LoftInput, MeshAsset, MirrorInput, ModelTreeItem, MultiTransformStep, MultiTransformValue, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PathCommandValue, PathPropertyValue, PersistenceCapabilities, PipeInput, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProfileClassification, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RemoveObjectInput, ReorderBodyFeatureInput, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, ShapeMassProperties, ShapeQualityReport, SubshapeRef, SubshapeSelection, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyAdjacency, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue, ViewportInteractionHandlers, ViewportMeshAsset } from './types'

View File

@@ -47,6 +47,7 @@ import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTo
import { createCamJob } from './cam' import { createCamJob } from './cam'
import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates' import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates'
import { assertPartDesignParameterSet, parameterValuesForObject, validatePartDesignParameterSet } from './partDesignParameters' import { assertPartDesignParameterSet, parameterValuesForObject, validatePartDesignParameterSet } from './partDesignParameters'
import { freecadPropertyRecomputeEffect, isFreecadPropertyDocumentModifiedSuppressed, isFreecadPropertyReadOnly } from './propertyStatus'
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('ellipsoid') ? 'Part::Ellipsoid' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('torus') ? 'Part::Torus' : item.id.startsWith('helix') ? 'Part::Helix' : item.id.startsWith('prism') ? 'Part::Prism' : item.id.startsWith('wedge') ? 'Part::Wedge' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('groove') ? 'PartDesign::Groove' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('multi-transform') ? 'PartDesign::MultiTransform' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('ellipsoid') ? 'Part::Ellipsoid' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('torus') ? 'Part::Torus' : item.id.startsWith('helix') ? 'Part::Helix' : item.id.startsWith('prism') ? 'Part::Prism' : item.id.startsWith('wedge') ? 'Part::Wedge' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('groove') ? 'PartDesign::Groove' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('multi-transform') ? 'PartDesign::MultiTransform' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
@@ -532,7 +533,7 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
} }
const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPropertySnapshot, value: PropertyValue) => { const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPropertySnapshot, value: PropertyValue) => {
if (property.readOnly) throw new Error(`${property.label} is read-only.`) if (property.readOnly || isFreecadPropertyReadOnly(property.nativeStatus)) throw new Error(`${property.label} is read-only.`)
if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`) if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`)
if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`) if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`)
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyDistance' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyQuantityConstraint' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger' || property.type === 'App::PropertyIntegerConstraint') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`) if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyDistance' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyQuantityConstraint' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger' || property.type === 'App::PropertyIntegerConstraint') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`)
@@ -689,15 +690,17 @@ const expectedExpressionValue = (property: ObjectPropertySnapshot, expression: s
return { value, references: result.references } return { value, references: result.references }
} }
const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable<string>) => { const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable<string>, includeRoots = true) => {
const roots = new Set(objectIds)
const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id)) const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id))
const plan = graph.plan(objectIds) const plan = graph.plan(roots)
const affected = includeRoots ? plan.affected : plan.affected.filter((objectId) => !roots.has(objectId))
const recompute = document.recompute ?? createRecomputeSnapshot(document.objects.map((object) => object.id)) const recompute = document.recompute ?? createRecomputeSnapshot(document.objects.map((object) => object.id))
const objectStates = { ...recompute.objectStates } const objectStates = { ...recompute.objectStates }
for (const objectId of plan.affected) objectStates[objectId] = 'touched' for (const objectId of affected) objectStates[objectId] = 'touched'
const dirtyObjects = Object.entries(objectStates).filter(([, status]) => status === 'touched' || status === 'error' || status === 'upstream-failed').map(([objectId]) => objectId) const dirtyObjects = Object.entries(objectStates).filter(([, status]) => status === 'touched' || status === 'error' || status === 'upstream-failed').map(([objectId]) => objectId)
document.recompute = { ...recompute, status: 'idle', objectStates, dirtyObjects, order: [], errors: [] } document.recompute = { ...recompute, status: 'idle', objectStates, dirtyObjects, order: [], errors: [] }
for (const objectId of plan.affected) { for (const objectId of affected) {
const item = document.tree.find((candidate) => candidate.id === objectId) const item = document.tree.find((candidate) => candidate.id === objectId)
if (item && item.state !== 'active' && item.state !== 'readonly') item.state = 'dirty' if (item && item.state !== 'active' && item.state !== 'readonly') item.state = 'dirty'
const object = document.objects.find((candidate) => candidate.id === objectId) const object = document.objects.find((candidate) => candidate.id === objectId)
@@ -1072,9 +1075,10 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
const treeItem = document.tree.find((item) => item.id === objectId) const treeItem = document.tree.find((item) => item.id === objectId)
if (propertyName === 'Label' && treeItem) treeItem.label = String(value) if (propertyName === 'Label' && treeItem) treeItem.label = String(value)
document.dependencies = collectDependencyEdges(document) document.dependencies = collectDependencyEdges(document)
if (sourceProperty.recompute) markDocumentTouched(document, [objectId]) const recomputeEffect = freecadPropertyRecomputeEffect(sourceProperty.nativeStatus)
if (sourceProperty.recompute && recomputeEffect !== 'none') markDocumentTouched(document, [objectId], recomputeEffect === 'owner')
document.version += 1 document.version += 1
document.dirty = true document.dirty = isFreecadPropertyDocumentModifiedSuppressed(sourceProperty.nativeStatus) ? state.document.dirty : true
commit({ ...state, document }) commit({ ...state, document })
notify(`${sourceProperty.label} updated`) notify(`${sourceProperty.label} updated`)
} }
@@ -1160,7 +1164,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
const propertyIndex = sourceObject.properties.findIndex((property) => property.name === propertyName) const propertyIndex = sourceObject.properties.findIndex((property) => property.name === propertyName)
if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`) if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`)
const sourceProperty = sourceObject.properties[propertyIndex] const sourceProperty = sourceObject.properties[propertyIndex]
if (sourceProperty.readOnly) throw new Error(`${sourceProperty.label} is read-only.`) if (sourceProperty.readOnly || isFreecadPropertyReadOnly(sourceProperty.nativeStatus)) throw new Error(`${sourceProperty.label} is read-only.`)
if (!['App::PropertyLength', 'App::PropertyAngle', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`) if (!['App::PropertyLength', 'App::PropertyAngle', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`)
const evaluated = expectedExpressionValue(sourceProperty, expression, state.document) const evaluated = expectedExpressionValue(sourceProperty, expression, state.document)
const references = expressionReferences(expression) const references = expressionReferences(expression)
@@ -1172,9 +1176,10 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
property.expression = expression.trim() property.expression = expression.trim()
property.expressionError = undefined property.expressionError = undefined
document.dependencies = collectDependencyEdges(document) document.dependencies = collectDependencyEdges(document)
if (sourceProperty.recompute) markDocumentTouched(document, [objectId]) const recomputeEffect = freecadPropertyRecomputeEffect(sourceProperty.nativeStatus)
if (sourceProperty.recompute && recomputeEffect !== 'none') markDocumentTouched(document, [objectId], recomputeEffect === 'owner')
document.version += 1 document.version += 1
document.dirty = true document.dirty = isFreecadPropertyDocumentModifiedSuppressed(sourceProperty.nativeStatus) ? state.document.dirty : true
commit({ ...state, document }) commit({ ...state, document })
notify(`${sourceProperty.label} expression updated`) notify(`${sourceProperty.label} expression updated`)
} }

View File

@@ -0,0 +1,93 @@
export const FREECAD_PROPERTY_STATUS_POSITIONS = {
Touched: 0,
Immutable: 1,
ReadOnly: 2,
Hidden: 3,
Transient: 4,
MaterialEdit: 5,
NoMaterialListEdit: 6,
Output: 7,
LockDynamic: 8,
NoModify: 9,
PartialTrigger: 10,
NoRecompute: 11,
Single: 12,
Ordered: 13,
EvalOnRestore: 14,
Busy: 15,
CopyOnChange: 16,
UserEdit: 17,
DisableNotify: 18,
PropDynamic: 21,
PropNoPersist: 22,
PropNoRecompute: 23,
PropReadOnly: 24,
PropTransient: 25,
PropHidden: 26,
PropOutput: 27,
User1: 28,
User2: 29,
User3: 30,
User4: 31,
} as const
export type FreecadPropertyStatusName = keyof typeof FREECAD_PROPERTY_STATUS_POSITIONS
export type FreecadPropertyStatus = {
mask: number
names: FreecadPropertyStatusName[]
unknownBits: number[]
}
const namesByPosition = new Map<number, FreecadPropertyStatusName>(
Object.entries(FREECAD_PROPERTY_STATUS_POSITIONS).map(([name, position]) => [position, name as FreecadPropertyStatusName]),
)
export const normalizeFreecadPropertyStatusMask = (value: number): number => {
if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) throw new RangeError('FreeCAD Property status mask must be an unsigned 32-bit integer.')
return value >>> 0
}
export const decodeFreecadPropertyStatus = (value: number): FreecadPropertyStatus => {
const mask = normalizeFreecadPropertyStatusMask(value)
const names: FreecadPropertyStatusName[] = []
const unknownBits: number[] = []
for (let position = 0; position < 32; position += 1) {
if ((mask & (1 << position)) === 0) continue
const name = namesByPosition.get(position)
if (name) names.push(name)
else unknownBits.push(position)
}
return { mask, names, unknownBits }
}
export const encodeFreecadPropertyStatus = (names: readonly FreecadPropertyStatusName[]): number => {
let mask = 0
for (const name of new Set(names)) mask = (mask | (1 << FREECAD_PROPERTY_STATUS_POSITIONS[name])) >>> 0
return mask
}
export const hasFreecadPropertyStatus = (mask: number | undefined, ...names: FreecadPropertyStatusName[]): boolean => {
if (mask === undefined) return false
const normalized = normalizeFreecadPropertyStatusMask(mask)
return names.some((name) => (normalized & (1 << FREECAD_PROPERTY_STATUS_POSITIONS[name])) !== 0)
}
export const isFreecadPropertyReadOnly = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Immutable', 'ReadOnly', 'PropReadOnly')
export const isFreecadPropertyHidden = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Hidden', 'PropHidden')
export const isFreecadPropertyOutput = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Output', 'PropOutput')
export const isFreecadPropertyRecomputeSuppressed = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Output', 'PropOutput', 'PropNoRecompute')
export const isFreecadPropertyNoPersist = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'PropNoPersist')
export const isFreecadPropertyTransient = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Transient', 'PropTransient')
export const isFreecadPropertyPersistenceSuppressed = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Transient', 'PropNoPersist', 'PropTransient')
export const isFreecadPropertyDocumentModifiedSuppressed = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'NoModify')
export const isFreecadPropertyDynamicMutationLocked = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'LockDynamic')
export type FreecadPropertyRecomputeEffect = 'none' | 'dependents' | 'owner'
/** Mirrors App::DocumentObject::onChanged for the locked FreeCAD runtime. */
export const freecadPropertyRecomputeEffect = (mask: number | undefined): FreecadPropertyRecomputeEffect => {
if (isFreecadPropertyOutput(mask)) return 'none'
if (hasFreecadPropertyStatus(mask, 'PropNoRecompute')) return 'dependents'
return 'owner'
}

View File

@@ -97,6 +97,8 @@ export type ObjectPropertySnapshot = {
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyDistance' | 'App::PropertyAngle' | 'App::PropertyQuantityConstraint' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyLinkSubList' | 'App::PropertyLinkList' | 'App::PropertyStringList' | 'App::PropertyFloatList' | 'App::PropertyIntegerList' | 'App::PropertyVector' | 'App::PropertyPlacement' | 'App::PropertyMultiTransform' | 'Path::PropertyPath' | 'Part::PropertyPartShape' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger' | 'App::PropertyIntegerConstraint' type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyDistance' | 'App::PropertyAngle' | 'App::PropertyQuantityConstraint' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyLinkSubList' | 'App::PropertyLinkList' | 'App::PropertyStringList' | 'App::PropertyFloatList' | 'App::PropertyIntegerList' | 'App::PropertyVector' | 'App::PropertyPlacement' | 'App::PropertyMultiTransform' | 'Path::PropertyPath' | 'Part::PropertyPartShape' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger' | 'App::PropertyIntegerConstraint'
value: PropertyValue value: PropertyValue
unit?: string unit?: string
/** Raw App::Property::Status bitmask from the locked FreeCAD runtime. */
nativeStatus?: number
readOnly?: boolean readOnly?: boolean
hidden?: boolean hidden?: boolean
recompute?: boolean recompute?: boolean

View File

@@ -1,7 +1,7 @@
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { strToU8, unzipSync, zipSync } from 'fflate' import { strToU8, unzipSync, zipSync } from 'fflate'
import { createMockFacade } from '../src/facade/mockFacade' import { createMockFacade, createWebCadFacade } from '../src/facade/mockFacade'
import { createProductionFacade } from '../src/facade/productionFacade' import { createProductionFacade } from '../src/facade/productionFacade'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from '../src/facade/projectSchema' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from '../src/facade/projectSchema'
import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore' import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
@@ -30,6 +30,7 @@ import { resolveMeshSubshape, resolveScreenBoxSelection } from '../src/facade/th
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine' import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
import { decodeFcstdPropertyValue, extractFcstdShapeResources, inspectFcstdArchive, instantiateFcstdShapeResource, rewriteFcstdMetadataArchive, serializeFcstdMetadataArchive, serializeStringHasherTableResource, storeFcstdShapeResources } from '../src/facade/fcstd' import { decodeFcstdPropertyValue, extractFcstdShapeResources, inspectFcstdArchive, instantiateFcstdShapeResource, rewriteFcstdMetadataArchive, serializeFcstdMetadataArchive, serializeStringHasherTableResource, storeFcstdShapeResources } from '../src/facade/fcstd'
import { ATTACHMENT_MAP_MODES, composeAttachmentPlacement, identityAttachmentOffset, validateAttachmentMapMode, validateAttachmentOffset, validateAttachmentSupport } from '../src/facade/attachment' import { ATTACHMENT_MAP_MODES, composeAttachmentPlacement, identityAttachmentOffset, validateAttachmentMapMode, validateAttachmentOffset, validateAttachmentSupport } from '../src/facade/attachment'
import { decodeFreecadPropertyStatus, encodeFreecadPropertyStatus, freecadPropertyRecomputeEffect, isFreecadPropertyDocumentModifiedSuppressed, isFreecadPropertyHidden, isFreecadPropertyPersistenceSuppressed, isFreecadPropertyReadOnly, isFreecadPropertyRecomputeSuppressed, normalizeFreecadPropertyStatusMask } from '../src/facade/propertyStatus'
import { redirectBodyTips, resolveBodyTip } from '../src/facade/bodyRules' import { redirectBodyTips, resolveBodyTip } from '../src/facade/bodyRules'
import { assertShapeHandleIntegrity, BitbybitGeometryRuntime, classifyPlanarProfile, collectGeometryImportText, MAX_GEOMETRY_IMPORT_TEXT_BYTES, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validateGeometryFileImport, validateGrooveInput, validateLoftInput, validateMirrorInput, validatePadInput, validatePipeInput, validatePlacementInput, validatePlanarProfile, validatePrismInput, validateRevolutionInput, validateSphereInput, validateTorusInput } from '../src/facade/geometryRuntime' import { assertShapeHandleIntegrity, BitbybitGeometryRuntime, classifyPlanarProfile, collectGeometryImportText, MAX_GEOMETRY_IMPORT_TEXT_BYTES, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validateGeometryFileImport, validateGrooveInput, validateLoftInput, validateMirrorInput, validatePadInput, validatePipeInput, validatePlacementInput, validatePlanarProfile, validatePrismInput, validateRevolutionInput, validateSphereInput, validateTorusInput } from '../src/facade/geometryRuntime'
import type { DocumentObjectSnapshot, DocumentSnapshot, MultiTransformValue, NativeTopologyHistoryInput, ObjectTopologySnapshot, PlanarProfile, Point3, ShapeHandle, SubshapeTopology, TopoRefValue } from '../src/facade/types' import type { DocumentObjectSnapshot, DocumentSnapshot, MultiTransformValue, NativeTopologyHistoryInput, ObjectTopologySnapshot, PlanarProfile, Point3, ShapeHandle, SubshapeTopology, TopoRefValue } from '../src/facade/types'
@@ -4496,6 +4497,83 @@ test('FCStd metadata writer round-trips Document and GuiDocument while preservin
assert.throws(() => serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Document.xml': new Uint8Array() } }), /reserved path/) assert.throws(() => serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Document.xml': new Uint8Array() } }), /reserved path/)
}) })
test('FreeCAD Property status masks preserve all source bits and enforce native touch behavior', () => {
const mask = encodeFreecadPropertyStatus(['Immutable', 'Hidden', 'Output', 'NoModify', 'NoRecompute', 'Ordered', 'PropNoPersist', 'PropNoRecompute', 'PropReadOnly', 'PropHidden', 'User4'])
assert.deepEqual(decodeFreecadPropertyStatus(mask), {
mask,
names: ['Immutable', 'Hidden', 'Output', 'NoModify', 'NoRecompute', 'Ordered', 'PropNoPersist', 'PropNoRecompute', 'PropReadOnly', 'PropHidden', 'User4'],
unknownBits: [],
})
assert.equal(isFreecadPropertyReadOnly(mask), true)
assert.equal(isFreecadPropertyHidden(mask), true)
assert.equal(isFreecadPropertyRecomputeSuppressed(mask), true)
assert.equal(isFreecadPropertyPersistenceSuppressed(mask), true)
assert.equal(isFreecadPropertyDocumentModifiedSuppressed(mask), true)
assert.equal(freecadPropertyRecomputeEffect(encodeFreecadPropertyStatus(['NoRecompute'])), 'owner')
assert.equal(freecadPropertyRecomputeEffect(encodeFreecadPropertyStatus(['PropNoRecompute'])), 'dependents')
assert.equal(freecadPropertyRecomputeEffect(encodeFreecadPropertyStatus(['Output'])), 'none')
assert.throws(() => normalizeFreecadPropertyStatusMask(-1), /unsigned 32-bit/)
assert.throws(() => normalizeFreecadPropertyStatusMask(0x1_0000_0000), /unsigned 32-bit/)
const document = recomputeDocumentFixture()
document.dirty = false
document.tree = [{ id: 'Box', label: 'Box', type: 'feature', state: 'up-to-date' }]
document.objects = [{ id: 'Box', typeId: 'Part::Box', properties: [
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, recompute: true, nativeStatus: encodeFreecadPropertyStatus(['NoModify', 'Output']) },
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, recompute: true, nativeStatus: encodeFreecadPropertyStatus(['PropReadOnly']) },
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, recompute: true, nativeStatus: encodeFreecadPropertyStatus(['PropNoRecompute']) },
] }, { id: 'Follower', typeId: 'Part::Feature', properties: [
{ name: 'Base', label: 'Base', group: 'Base', scope: 'data', type: 'App::PropertyLink', value: 'Box', recompute: true },
] }]
document.tree.push({ id: 'Follower', label: 'Follower', type: 'feature', state: 'up-to-date' })
document.dependencies = [{ sourceId: 'Follower', targetId: 'Box', relation: 'link', propertyName: 'Base' }]
document.recompute = { generation: 0, status: 'idle', objectStates: { Box: 'up-to-date', Follower: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] }
const facade = createWebCadFacade({ runtimeMode: 'mock', initialDocument: document })
facade.app.document.setProperty({ objectId: 'Box', propertyName: 'Length', value: 12 })
assert.equal(facade.app.document.getObject('Box')?.properties[0].value, 12)
assert.equal(facade.app.document.getActive().dirty, false)
assert.deepEqual(facade.app.document.getActive().recompute?.dirtyObjects, [])
assert.throws(() => facade.app.document.setProperty({ objectId: 'Box', propertyName: 'Width', value: 12 }), /read-only/)
facade.app.document.setProperty({ objectId: 'Box', propertyName: 'Height', value: 12 })
assert.deepEqual(facade.app.document.getActive().recompute?.dirtyObjects, ['Follower'])
facade.geometry.dispose()
})
test('FCStd Property status preserves transient definitions while NoPersist stays out of the archive', () => {
const nativeStatus = encodeFreecadPropertyStatus(['Ordered', 'PropNoRecompute', 'PropReadOnly', 'PropHidden', 'PropOutput'])
const document = recomputeDocumentFixture()
document.tree = [{ id: 'Box', label: 'Box', type: 'feature' }]
document.objects = [{ id: 'Box', typeId: 'Part::Box', properties: [
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 12.5, nativeStatus },
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 3, nativeStatus: encodeFreecadPropertyStatus(['PropNoPersist']) },
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 4, nativeStatus: encodeFreecadPropertyStatus(['Transient']) },
{ name: 'WebScratch', label: 'Web scratch', group: 'Web', scope: 'data', type: 'App::PropertyInteger', value: 9, nativeStatus: encodeFreecadPropertyStatus(['PropDynamic', 'PropTransient']) },
] }]
document.dependencies = []
const archive = serializeFcstdMetadataArchive(document)
const documentXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
assert.match(documentXml, new RegExp(`<Property name="Length" type="App::PropertyLength" status="${nativeStatus}">`))
assert.doesNotMatch(documentXml, /<Property name="Width"/)
assert.match(documentXml, new RegExp(`<_Property name="Height" type="App::PropertyLength" status="${encodeFreecadPropertyStatus(['Transient'])}"\/>`))
assert.match(documentXml, new RegExp(`<Property name="WebScratch" type="App::PropertyInteger"[^>]+status="${encodeFreecadPropertyStatus(['PropDynamic', 'PropTransient'])}"><\/Property>`))
assert.doesNotMatch(documentXml, /<Integer value="9"/)
const properties = inspectFcstdArchive(archive).objects[0].properties
assert.deepEqual(properties.find((property) => property.name === 'Length'), {
name: 'Length', typeId: 'App::PropertyLength', element: 'Float', value: '12.5', nativeStatus,
statusNames: ['Ordered', 'PropNoRecompute', 'PropReadOnly', 'PropHidden', 'PropOutput'],
})
assert.equal(properties.some((property) => property.name === 'Width'), false)
assert.deepEqual(properties.find((property) => property.name === 'Height'), {
name: 'Height', typeId: 'App::PropertyLength', element: '', value: '', nativeStatus: encodeFreecadPropertyStatus(['Transient']), statusNames: ['Transient'], transientMetadata: true,
})
assert.deepEqual(properties.find((property) => property.name === 'WebScratch'), {
name: 'WebScratch', typeId: 'App::PropertyInteger', element: '', value: '', nativeStatus: encodeFreecadPropertyStatus(['PropDynamic', 'PropTransient']), statusNames: ['PropDynamic', 'PropTransient'],
})
const invalidStatus = '<Document><Objects Count="1"><Object name="Box" type="Part::Box"/></Objects><ObjectData Count="1"><Object name="Box"><Properties Count="1"><Property name="Length" type="App::PropertyLength" status="4294967296"><Float value="1"/></Property></Properties></Object></ObjectData></Document>'
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(invalidStatus) })), /unsigned 32-bit/)
})
test('FCStd typed property codec decodes numeric, boolean and LinkSub values without executing expressions', () => { test('FCStd typed property codec decodes numeric, boolean and LinkSub values without executing expressions', () => {
assert.deepEqual(decodeFcstdPropertyValue({ name: 'Length', typeId: 'App::PropertyLength', element: 'Length', value: '12.5' }), { value: 12.5, decoded: true }) assert.deepEqual(decodeFcstdPropertyValue({ name: 'Length', typeId: 'App::PropertyLength', element: 'Length', value: '12.5' }), { value: 12.5, decoded: true })
assert.deepEqual(decodeFcstdPropertyValue({ name: 'Polygon', typeId: 'App::PropertyIntegerConstraint', element: 'Integer', value: '6' }), { value: 6, decoded: true }) assert.deepEqual(decodeFcstdPropertyValue({ name: 'Polygon', typeId: 'App::PropertyIntegerConstraint', element: 'Integer', value: '6' }), { value: 6, decoded: true })