feat: align FreeCAD property status semantics
This commit is contained in:
@@ -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.
|
||||
- FreeCAD-style application menus, workbench navigation, Combo View, Task Dock, viewport and Report view
|
||||
- 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`
|
||||
- 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)
|
||||
@@ -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`
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-08-11T04:50:53.969Z",
|
||||
"generatedAt": "2026-08-14T14:24:04.328Z",
|
||||
"status": "pass",
|
||||
"browsers": [
|
||||
{
|
||||
@@ -38,7 +38,7 @@
|
||||
"height": 579,
|
||||
"geometrySource": "bitbybit-occt"
|
||||
},
|
||||
"screenshotBytes": 128023,
|
||||
"screenshotBytes": 121616,
|
||||
"pageErrors": []
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-08-11T02:37:40.468Z",
|
||||
"generatedAt": "2026-08-14T14:26:21.996Z",
|
||||
"browserId": "chrome",
|
||||
"status": "pass",
|
||||
"browser": {
|
||||
@@ -423,18 +423,18 @@
|
||||
"screenshots": {
|
||||
"desktop": {
|
||||
"path": "fixtures/chrome-app-e2e/desktop-workspace.png",
|
||||
"byteLength": 108743,
|
||||
"sha256": "dd67ab23283ffd55dc0509174f8636eac5344de581d4cacfff13cc1828617399"
|
||||
"byteLength": 109165,
|
||||
"sha256": "f5030d4e3ae88107e06a5e8147a196b5f0eb7748c7c306b4118f97a98dd9bee4"
|
||||
},
|
||||
"cam": {
|
||||
"path": "fixtures/chrome-app-e2e/cam-workbench.png",
|
||||
"byteLength": 118389,
|
||||
"sha256": "c339e4b998b8ce7b3290c32efeffebed85dbd1d56d8ae9f764196c7b52e80a8f"
|
||||
"byteLength": 118390,
|
||||
"sha256": "5349262af666380bbc96f2932eed2613547ae60870a1d8b9fe44d8ed4831b4ab"
|
||||
},
|
||||
"mobile": {
|
||||
"path": "fixtures/chrome-app-e2e/mobile-start.png",
|
||||
"byteLength": 130371,
|
||||
"sha256": "9d69c9e9ced4b45bbd083a37a94b5df074e8d60540c0857e79b9df08a8648d81"
|
||||
"byteLength": 130361,
|
||||
"sha256": "c65c8644728cb488b22d683a3b68b770dccdfe2107dc9ab5413bfdda72ff9ab1"
|
||||
}
|
||||
},
|
||||
"pageErrors": []
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"scale": {
|
||||
"components": 1000,
|
||||
"bomRows": 10,
|
||||
"durationMs": 6.685000061988831,
|
||||
"durationMs": 11.384999990463257,
|
||||
"budgetMs": 1000
|
||||
},
|
||||
"fcstd": {
|
||||
@@ -44,7 +44,7 @@
|
||||
"persistence": {
|
||||
"mode": "sqlite-opfs",
|
||||
"byteLength": 980,
|
||||
"hash": "93bbadc1517ccf2443c74578e92bea6d13c01b02976a53630707f5e7002a6066",
|
||||
"hash": "5482e205a876ef4516106d3819ee59a6873ada3f6e48d85df78ed123c073fd3b",
|
||||
"roundTrip": true,
|
||||
"released": true
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"persistence": {
|
||||
"mode": "sqlite-opfs",
|
||||
"byteLength": 848,
|
||||
"hash": "7a55c8b5c5f1f5354c2819c6289c9ae60351f7c0d6d1844e51eea2785ce7fad8",
|
||||
"hash": "ec7b7361b0ea5caad7776e5cf851909745150e0de004840187ce000d268794e0",
|
||||
"roundTrip": true,
|
||||
"released": true
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"status": "pass",
|
||||
"status": "failed",
|
||||
"stages": [
|
||||
"CAD",
|
||||
"OCL",
|
||||
@@ -29,15 +29,7 @@
|
||||
"hasC359": true,
|
||||
"hasC361": true
|
||||
},
|
||||
"machine": {
|
||||
"backend": "linuxcnc-wasm",
|
||||
"status": "accepted",
|
||||
"dryRunStatus": "dry-run",
|
||||
"parserAuthority": "linuxcnc-wasm",
|
||||
"lines": 65,
|
||||
"blocks": 138,
|
||||
"trajectorySegments": 48
|
||||
},
|
||||
"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",
|
||||
"browserId": "chrome",
|
||||
"browser": {
|
||||
"product": "150.0.7871.128",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"multiAxis": true
|
||||
},
|
||||
"geometry": {
|
||||
"sourceShape": "shape-msjl38sz-1",
|
||||
"sourceShape": "shape-mst1ison-1",
|
||||
"valid": true,
|
||||
"volume": 30
|
||||
},
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
},
|
||||
"facadeCapabilities": {
|
||||
"geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "object-placement-recompute", "placement-validation", "mirror-plane", "mirrored-whole-shape", "multi-transform-whole-shape", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "loft", "pipe", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "native-cross-feature-history-routing", "step-export", "stl-export"], "featureLevels": { "pad": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "pocket": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "revolution": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it"] }, "groove": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it", "OCCT/FreeCAD private token generation"] }, "boolean": { "level": "compatible", "exactBlockedBy": ["native MappedNameRef/StringHasher evidence when provider omits it", "isomorphic source ambiguity"] }, "fillet": { "level": "experimental", "exactBlockedBy": ["complete cross-feature native stage history"] }, "chamfer": { "level": "experimental", "exactBlockedBy": ["complete cross-feature native stage history"] }, "multi-transform": { "level": "experimental", "exactBlockedBy": ["feature-list native history", "private naming tokens"] } } },
|
||||
"document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-multi-transform", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "generation-topology-snapshot", "linksub-external-geometry-migration", "ambiguous-deleted-topology-diagnostics", "adjacency-fingerprint-disambiguation", "toporef-candidate-replacement-transaction", "signature-topology-history-fallback", "native-topology-history-adapter", "native-cross-feature-history-routing", "runtime-mapped-name-ref-evidence", "string-hasher-evidence-validation", "element-map2-multi-stage-name-mapping", "isomorphic-candidate-persistence", "element-map-name-contract", "undo-redo"] },
|
||||
"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"] },
|
||||
"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"] },
|
||||
|
||||
@@ -862,9 +862,6 @@
|
||||
],
|
||||
"facadeNative": [
|
||||
"Hidden",
|
||||
"ReadOnly"
|
||||
],
|
||||
"proxyOnly": [
|
||||
"Immutable",
|
||||
"LockDynamic",
|
||||
"NoModify",
|
||||
@@ -877,17 +874,39 @@
|
||||
"PropOutput",
|
||||
"PropReadOnly",
|
||||
"PropTransient",
|
||||
"ReadOnly",
|
||||
"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": []
|
||||
},
|
||||
"harness": {
|
||||
"path": "scripts/run-freecad-native-property-semantics.mjs",
|
||||
"bytes": 5658,
|
||||
"sha256": "35eb62dc7988ac5f373fb25d1558ab830ecb42b261009fbd79638ee428800331"
|
||||
"bytes": 5949,
|
||||
"sha256": "fb7fb74e4979b284e7a0f637d820bd183cea8e5cfb1348fd1730a891ac1306a2"
|
||||
},
|
||||
"exactPromotionReady": false,
|
||||
"exactBlocker": "complete native document and property semantics",
|
||||
"generatedAt": "2026-08-14T12:20:59.613Z"
|
||||
"generatedAt": "2026-08-14T15:36:12.139Z"
|
||||
}
|
||||
|
||||
223
config/freecad-property-status-oracle.json
Normal file
223
config/freecad-property-status-oracle.json
Normal 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"
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
"id": "EX03",
|
||||
"title": "Application document, property and recompute semantics",
|
||||
"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-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"] },
|
||||
|
||||
101
docs/current-work.zh-CN.md
Normal file
101
docs/current-work.zh-CN.md
Normal 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 |
@@ -22,7 +22,9 @@
|
||||
"generate:freecad-type-properties": "node scripts/generate-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",
|
||||
"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",
|
||||
"check:freecad-gui-commands": "node scripts/check-freecad-gui-command-inventory.mjs",
|
||||
"generate:freecad-entrypoints": "node scripts/generate-freecad-entrypoint-inventory.mjs",
|
||||
|
||||
@@ -10,10 +10,11 @@ if (report.runtime?.registeredObjectTypes !== 352 || report.runtime.instantiable
|
||||
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 (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]) {
|
||||
const path = resolve(root, locked?.path ?? '')
|
||||
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}`)
|
||||
}
|
||||
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))
|
||||
|
||||
22
scripts/check-freecad-property-status-oracle.mjs
Normal file
22
scripts/check-freecad-property-status-oracle.mjs
Normal 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))
|
||||
145
scripts/freecad-property-status-oracle.py
Normal file
145
scripts/freecad-property-status-oracle.py
Normal 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=(",", ":")))
|
||||
@@ -29,7 +29,10 @@ const numericStatusNames = new Map([
|
||||
[13, 'Ordered'], [22, 'PropNoPersist'], [23, 'PropNoRecompute'], [24, 'PropReadOnly'],
|
||||
[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 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')
|
||||
@@ -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) }]
|
||||
}))
|
||||
const observedStatuses = [...new Set(properties.flatMap((property) => property.status))].sort()
|
||||
const unsupportedStatusNames = observedStatuses.filter((status) => !facadeStatusNames.has(status))
|
||||
const unsupportedStatusRecordCount = properties.filter((property) => property.status.some((status) => unsupportedStatusNames.includes(status))).length
|
||||
const preservedOnlyStatusNames = observedStatuses.filter((status) => !facadeBehaviorStatusNames.has(status))
|
||||
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 harnessContent = await readFile(harnessPath)
|
||||
const report = {
|
||||
@@ -68,9 +71,12 @@ const report = {
|
||||
types,
|
||||
propertyStatus: {
|
||||
observed: observedStatuses,
|
||||
facadeNative: [...facadeStatusNames].sort(),
|
||||
proxyOnly: unsupportedStatusNames,
|
||||
proxyOnlyRecordCount: unsupportedStatusRecordCount,
|
||||
facadeNative: observedStatuses,
|
||||
behaviorNative: [...facadeBehaviorStatusNames].sort(),
|
||||
preservedOnly: preservedOnlyStatusNames,
|
||||
preservedOnlyRecordCount: preservedOnlyStatusRecordCount,
|
||||
proxyOnly: [],
|
||||
proxyOnlyRecordCount: 0,
|
||||
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') },
|
||||
|
||||
27
scripts/run-freecad-property-status-oracle.mjs
Normal file
27
scripts/run-freecad-property-status-oracle.mjs
Normal 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))
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
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 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 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 }) {
|
||||
const object = facade.app.document.getObject(objectId)
|
||||
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[]>()
|
||||
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>
|
||||
@@ -986,7 +987,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
|
||||
}
|
||||
const formatted = `${String(property.value ?? '')}${property.unit ? ` ${property.unit}` : ''}`
|
||||
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::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') {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { unzipSync, zipSync } from 'fflate'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import { ATTACHMENT_MAP_MODES, validateAttachmentOffset, validateAttachmentSupport } from './attachment'
|
||||
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 type { AnyElementMap2Document, ElementMap2Document } from './elementMap2'
|
||||
import { migrateStringHasherSchema, parseStringHasherTable, validateElementMap2StringHasherEvidence, validateStringHasherTable, writeStringHasherTable } from './stringHasher'
|
||||
@@ -36,6 +38,9 @@ export type FcstdPropertySummary = {
|
||||
typeId: string
|
||||
element: string
|
||||
value: string
|
||||
nativeStatus?: number
|
||||
statusNames?: FreecadPropertyStatusName[]
|
||||
transientMetadata?: boolean
|
||||
subElements?: string[]
|
||||
links?: 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 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') {
|
||||
const value = pathPropertyValue(property.value, property.name)
|
||||
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.`)
|
||||
}
|
||||
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') {
|
||||
let objectId = ''
|
||||
@@ -1081,6 +1091,7 @@ const expressionEngineXml = (object: DocumentObjectSnapshot) => {
|
||||
}
|
||||
|
||||
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 []
|
||||
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) => {
|
||||
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 []
|
||||
return [property.value.elementMapResource]
|
||||
}))
|
||||
|
||||
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 []
|
||||
const value = pathPropertyValue(property.value, `${object.id}.${property.name}`)
|
||||
const path = value.resourcePath ?? `${object.id}.nc`
|
||||
@@ -1142,13 +1155,18 @@ export const serializeFcstdMetadataArchive = (document: DocumentSnapshot, option
|
||||
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>()
|
||||
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 expressionEngine = expressionEngineXml(object)
|
||||
const hasShapeProperty = properties.some((property) => property.type === 'Part::PropertyPartShape')
|
||||
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)
|
||||
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('')
|
||||
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))
|
||||
@@ -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.`)
|
||||
}
|
||||
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 => {
|
||||
@@ -2061,7 +2088,10 @@ const parseDocumentXml = (bytes: Uint8Array, limits: FcstdArchiveLimits) => {
|
||||
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 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
|
||||
return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, ...(nativeObjectTag === undefined ? {} : { nativeObjectTag }), propertyCount: properties.length, properties: summaries, support, extensions, ...(sketch ? { sketch } : {}) }
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './proj
|
||||
export type { CreateEllipsoidInput, CreateHelixInput, CreatePrismInput, CreateWedgeInput, ExtrudeInput } from './types'
|
||||
export type { DraftInput, ThicknessInput } 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 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'
|
||||
|
||||
@@ -47,6 +47,7 @@ import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTo
|
||||
import { createCamJob } from './cam'
|
||||
import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates'
|
||||
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'
|
||||
|
||||
@@ -532,7 +533,7 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
|
||||
}
|
||||
|
||||
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::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.`)
|
||||
@@ -689,15 +690,17 @@ const expectedExpressionValue = (property: ObjectPropertySnapshot, expression: s
|
||||
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 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 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)
|
||||
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)
|
||||
if (item && item.state !== 'active' && item.state !== 'readonly') item.state = 'dirty'
|
||||
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)
|
||||
if (propertyName === 'Label' && treeItem) treeItem.label = String(value)
|
||||
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.dirty = true
|
||||
document.dirty = isFreecadPropertyDocumentModifiedSuppressed(sourceProperty.nativeStatus) ? state.document.dirty : true
|
||||
commit({ ...state, document })
|
||||
notify(`${sourceProperty.label} updated`)
|
||||
}
|
||||
@@ -1160,7 +1164,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
|
||||
const propertyIndex = sourceObject.properties.findIndex((property) => property.name === propertyName)
|
||||
if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`)
|
||||
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.`)
|
||||
const evaluated = expectedExpressionValue(sourceProperty, expression, state.document)
|
||||
const references = expressionReferences(expression)
|
||||
@@ -1172,9 +1176,10 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
|
||||
property.expression = expression.trim()
|
||||
property.expressionError = undefined
|
||||
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.dirty = true
|
||||
document.dirty = isFreecadPropertyDocumentModifiedSuppressed(sourceProperty.nativeStatus) ? state.document.dirty : true
|
||||
commit({ ...state, document })
|
||||
notify(`${sourceProperty.label} expression updated`)
|
||||
}
|
||||
|
||||
93
src/facade/propertyStatus.ts
Normal file
93
src/facade/propertyStatus.ts
Normal 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'
|
||||
}
|
||||
@@ -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'
|
||||
value: PropertyValue
|
||||
unit?: string
|
||||
/** Raw App::Property::Status bitmask from the locked FreeCAD runtime. */
|
||||
nativeStatus?: number
|
||||
readOnly?: boolean
|
||||
hidden?: boolean
|
||||
recompute?: boolean
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
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 { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from '../src/facade/projectSchema'
|
||||
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 { 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 { decodeFreecadPropertyStatus, encodeFreecadPropertyStatus, freecadPropertyRecomputeEffect, isFreecadPropertyDocumentModifiedSuppressed, isFreecadPropertyHidden, isFreecadPropertyPersistenceSuppressed, isFreecadPropertyReadOnly, isFreecadPropertyRecomputeSuppressed, normalizeFreecadPropertyStatusMask } from '../src/facade/propertyStatus'
|
||||
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 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/)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user