diff --git a/ROADMAP.md b/ROADMAP.md index 497a94ef..6f38346b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,7 +52,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | -| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). Next: file format version and migrations, old-design corpus, fuzzed design files | in progress | 6 | +| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | ## Known limitations diff --git a/apps/solvecraft-cli/tests/corpus/README.md b/apps/solvecraft-cli/tests/corpus/README.md new file mode 100644 index 00000000..7215abbc --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/README.md @@ -0,0 +1,20 @@ +# Design file corpus + +Designs saved by older SolveCraft builds. `tests/design_corpus.rs` opens each one with the +current build and checks that it evaluates as it did when saved (the `.expected.json` next to +it is `solvecraft-cli eval` of that file by the build that saved it: bodies, volumes, areas, +world bounding boxes), that it is upgraded to the current file format, and that damaged and +hostile variants of it never panic. + +| File | Saved by | Era | +|---|---|---| +| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut | +| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit | +| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground | +| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter | +| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) | +| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) | +| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule | + +The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the +point is that it was written by the old one. Add new files for new eras instead. diff --git a/apps/solvecraft-cli/tests/corpus/components-arm.expected.json b/apps/solvecraft-cli/tests/corpus/components-arm.expected.json new file mode 100644 index 00000000..193fac1b --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/components-arm.expected.json @@ -0,0 +1,134 @@ +{ + "bodies": [ + { + "area_mm2": 700.5299915783324, + "bbox": { + "max": [ + 10.0, + 10.0, + 10.000000000000007 + ], + "min": [ + 0.0, + 0.0, + -6.080836915950756e-16 + ] + }, + "center_of_mass": [ + 4.999999998722992, + 4.999999999366269, + 4.999999996470206 + ], + "density_g_cm3": 1.29, + "edges": 14, + "face_types": { + "cylinder": 1, + "plane": 6 + }, + "faces": 7, + "kernel": { + "edges": 18, + "faces": 8, + "shells": 1, + "vertices": 12 + }, + "mass_g": 1.1279207478827844, + "material": "Default", + "name": "Base", + "vertices": 10, + "volume_mm3": 874.3571689013833 + } + ], + "body_count": 1, + "params": [], + "sketches": [ + { + "curves": 1, + "dof": 3, + "id": 11, + "name": "S1", + "profiles": 1, + "status": "solved" + } + ], + "timeline": [ + { + "error": null, + "id": 1, + "ms": 13.568090999999999, + "name": "Box1", + "rolled_back": false, + "suppressed": false, + "type": "BoxFeature", + "warning": null + }, + { + "error": null, + "id": 4, + "ms": 1.004123, + "name": "Box2", + "rolled_back": false, + "suppressed": false, + "type": "BoxFeature", + "warning": null + }, + { + "error": null, + "id": 5, + "ms": 6.818065, + "name": "Fillet1", + "rolled_back": false, + "suppressed": false, + "type": "FilletFeature", + "warning": null + }, + { + "error": null, + "id": 9, + "ms": 0.223829, + "name": "Box2 (copy)", + "rolled_back": false, + "suppressed": false, + "type": "BoxFeature", + "warning": null + }, + { + "error": null, + "id": 10, + "ms": 6.139636, + "name": "Fillet1 (copy)", + "rolled_back": false, + "suppressed": false, + "type": "FilletFeature", + "warning": null + }, + { + "error": null, + "id": 11, + "ms": 0.045728, + "name": "S1", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "error": null, + "id": 12, + "ms": 1537.323948, + "name": "Extrude1", + "rolled_back": false, + "suppressed": false, + "type": "ExtrudeFeature", + "warning": null + } + ], + "total": { + "area_mm2": 700.5299915783324, + "edges": 14, + "faces": 7, + "mass_g": 1.1279207478827844, + "vertices": 10, + "volume_mm3": 874.3571689013833 + } +} diff --git a/apps/solvecraft-cli/tests/corpus/components-arm.solvecraft b/apps/solvecraft-cli/tests/corpus/components-arm.solvecraft new file mode 100644 index 00000000..8fb36b01 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/components-arm.solvecraft @@ -0,0 +1,263 @@ +{ + "format": "solvecraft/1", + "name": "Untitled", + "units": "mm", + "params": [], + "features": [ + { + "id": 1, + "name": "Box1", + "suppressed": false, + "body_names": [ + "Base" + ], + "type": "box", + "corner": [ + 0.0, + 0.0, + 0.0 + ], + "length": "10", + "width": "10", + "height": "10", + "operation": "new_body" + }, + { + "id": 4, + "name": "Box2", + "suppressed": false, + "body_names": [ + "ArmBody" + ], + "component": 2, + "type": "box", + "corner": [ + 20.0, + 0.0, + 0.0 + ], + "length": "10", + "width": "4", + "height": "4", + "operation": "new_body" + }, + { + "id": 5, + "name": "Fillet1", + "suppressed": false, + "component": 2, + "type": "fillet", + "edges": [ + [ + 25.0, + 4.0, + 4.0 + ] + ], + "radius": "1" + }, + { + "id": 9, + "name": "Box2 (copy)", + "suppressed": false, + "component": 7, + "type": "box", + "corner": [ + 20.0, + 0.0, + 0.0 + ], + "length": "10", + "width": "4", + "height": "4", + "operation": "new_body" + }, + { + "id": 10, + "name": "Fillet1 (copy)", + "suppressed": false, + "component": 7, + "type": "fillet", + "edges": [ + [ + 25.0, + 4.0, + 4.0 + ] + ], + "radius": "1" + }, + { + "id": 11, + "name": "S1", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "origin", + "name": "XY" + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "c1.center", + "pos": [ + 5.0, + 5.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "c1", + "type": "circle", + "c": 1, + "r": 2.0, + "construction": false + } + ], + "constraints": [], + "counters": { + "c": 1 + } + } + }, + { + "id": 12, + "name": "Extrude1", + "suppressed": false, + "type": "extrude", + "sketch": 11, + "profiles": { + "type": "all" + }, + "extent": { + "distance": "10", + "direction": "positive" + }, + "operation": "cut" + } + ], + "next_id": 13, + "components": [ + { + "id": 2, + "name": "Arm", + "parent": 0 + }, + { + "id": 7, + "name": "Arm (copy)", + "parent": 0 + } + ], + "occurrences": [ + { + "id": 3, + "component": 2, + "parent": 0, + "name": "Arm:1", + "transform": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 1.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 1.0, + 0.0 + ], + [ + 0.0, + 50.0, + 0.0, + 1.0 + ] + ], + "grounded": true + }, + { + "id": 6, + "component": 2, + "parent": 0, + "name": "Arm:2", + "transform": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 1.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 1.0, + 0.0 + ], + [ + 0.0, + 70.0, + 0.0, + 1.0 + ] + ], + "grounded": false + }, + { + "id": 8, + "component": 7, + "parent": 0, + "name": "Arm (copy):1", + "transform": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 1.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 1.0, + 0.0 + ], + [ + 0.0, + 20.0, + 0.0, + 1.0 + ] + ], + "grounded": false + } + ] +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/corpus/joints-hinge.expected.json b/apps/solvecraft-cli/tests/corpus/joints-hinge.expected.json new file mode 100644 index 00000000..28908df3 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/joints-hinge.expected.json @@ -0,0 +1,217 @@ +{ + "bodies": [ + { + "area_mm2": 2820.0, + "bbox": { + "max": [ + 40.0, + 30.0, + 3.0 + ], + "min": [ + 0.0, + 0.0, + 0.0 + ] + }, + "center_of_mass": [ + 20.0, + 15.0, + 1.5 + ], + "density_g_cm3": 1.29, + "edges": 12, + "face_types": { + "plane": 6 + }, + "faces": 6, + "kernel": { + "edges": 12, + "faces": 6, + "shells": 1, + "vertices": 8 + }, + "mass_g": 4.644, + "material": "Default", + "name": "Body1", + "vertices": 8, + "volume_mm3": 3600.0 + }, + { + "area_mm2": 1105.7821673177034, + "bbox": { + "max": [ + 40.00000000000002, + 37.0, + 8.000000000000002 + ], + "min": [ + 0.0, + 29.0, + -6.661338147750943e-16 + ] + }, + "center_of_mass": [ + 20.00000000000423, + 32.99999999999611, + 3.9999999999995404 + ], + "density_g_cm3": 1.29, + "edges": 2, + "face_types": { + "cylinder": 1, + "plane": 2 + }, + "faces": 3, + "kernel": { + "edges": 6, + "faces": 4, + "shells": 1, + "vertices": 4 + }, + "mass_g": 2.593268071855663, + "material": "Default", + "name": "Body2", + "vertices": 2, + "volume_mm3": 2010.2853270198937 + }, + { + "area_mm2": 2820.0, + "bbox": { + "max": [ + 40.0, + 32.00000000000008, + 37.000000000000064 + ], + "min": [ + 0.0, + 29.000000000000075, + 7.000000000000064 + ] + }, + "center_of_mass": [ + 20.0, + 30.500000000000075, + 22.00000000000007 + ], + "density_g_cm3": 1.29, + "edges": 12, + "face_types": { + "plane": 6 + }, + "faces": 6, + "kernel": { + "edges": 12, + "faces": 6, + "shells": 1, + "vertices": 8 + }, + "mass_g": 4.643999999999996, + "material": "Default", + "name": "Body3", + "vertices": 8, + "volume_mm3": 3599.9999999999964 + }, + { + "area_mm2": 1105.782167317711, + "bbox": { + "max": [ + 40.00000000000002, + 37.000000000000085, + 8.000000000000064 + ], + "min": [ + 0.0, + 29.000000000000075, + 6.394884621840902e-14 + ] + }, + "center_of_mass": [ + 20.000000000004935, + 32.99999999999709, + 3.999999999999749 + ], + "density_g_cm3": 1.29, + "edges": 2, + "face_types": { + "cylinder": 1, + "plane": 2 + }, + "faces": 3, + "kernel": { + "edges": 6, + "faces": 4, + "shells": 1, + "vertices": 4 + }, + "mass_g": 2.59326807185558, + "material": "Default", + "name": "Body4", + "vertices": 2, + "volume_mm3": 2010.2853270198293 + } + ], + "body_count": 4, + "params": [ + { + "comment": "", + "error": null, + "expression": "90 deg", + "model": false, + "name": "open_angle", + "unit": "deg", + "value": 1.5707963267948966 + } + ], + "sketches": [], + "timeline": [ + { + "error": null, + "id": 3, + "ms": 1.1335799999999998, + "name": "Box1", + "rolled_back": false, + "suppressed": false, + "type": "BoxFeature", + "warning": null + }, + { + "error": null, + "id": 4, + "ms": 0.580462, + "name": "Cylinder1", + "rolled_back": false, + "suppressed": false, + "type": "CylinderFeature", + "warning": null + }, + { + "error": null, + "id": 7, + "ms": 0.257673, + "name": "Box2", + "rolled_back": false, + "suppressed": false, + "type": "BoxFeature", + "warning": null + }, + { + "error": null, + "id": 8, + "ms": 0.5234019999999999, + "name": "Cylinder2", + "rolled_back": false, + "suppressed": false, + "type": "CylinderFeature", + "warning": null + } + ], + "total": { + "area_mm2": 7851.564334635414, + "edges": 28, + "faces": 18, + "mass_g": 14.474536143711237, + "vertices": 20, + "volume_mm3": 11220.570654039719 + } +} diff --git a/apps/solvecraft-cli/tests/corpus/joints-hinge.solvecraft b/apps/solvecraft-cli/tests/corpus/joints-hinge.solvecraft new file mode 100644 index 00000000..d83f1767 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/joints-hinge.solvecraft @@ -0,0 +1,225 @@ +{ + "format": "solvecraft/1", + "name": "Untitled", + "units": "mm", + "params": [ + { + "name": "open_angle", + "expr": "90 deg", + "unit": "deg", + "model": false + } + ], + "features": [ + { + "id": 3, + "name": "Box1", + "suppressed": false, + "param_names": [ + "d1", + "d2", + "d3" + ], + "component": 1, + "type": "box", + "corner": [ + 0.0, + 0.0, + 0.0 + ], + "length": "40", + "width": "30", + "height": "3", + "operation": "new_body" + }, + { + "id": 4, + "name": "Cylinder1", + "suppressed": false, + "param_names": [ + "d4", + "d5" + ], + "component": 1, + "type": "cylinder", + "base": [ + 0.0, + 33.0, + 4.0 + ], + "axis": [ + 1.0, + 0.0, + 0.0 + ], + "radius": "4", + "height": "40", + "operation": "new_body" + }, + { + "id": 7, + "name": "Box2", + "suppressed": false, + "param_names": [ + "d6", + "d7", + "d8" + ], + "component": 5, + "type": "box", + "corner": [ + 0.0, + 0.0, + 0.0 + ], + "length": "40", + "width": "30", + "height": "3", + "operation": "new_body" + }, + { + "id": 8, + "name": "Cylinder2", + "suppressed": false, + "param_names": [ + "d9", + "d10" + ], + "component": 5, + "type": "cylinder", + "base": [ + 0.0, + 33.0, + 4.0 + ], + "axis": [ + 1.0, + 0.0, + 0.0 + ], + "radius": "4", + "height": "40", + "operation": "new_body" + } + ], + "next_id": 9, + "components": [ + { + "id": 1, + "name": "LeafA", + "parent": 0 + }, + { + "id": 5, + "name": "LeafB", + "parent": 0 + } + ], + "occurrences": [ + { + "id": 2, + "component": 1, + "parent": 0, + "name": "LeafA:1", + "transform": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 1.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 1.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + ], + "grounded": true + }, + { + "id": 6, + "component": 5, + "parent": 0, + "name": "LeafB:1", + "transform": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 6.123233995736766e-17, + -1.0, + 0.0 + ], + [ + 0.0, + 1.0, + 6.123233995736766e-17, + 0.0 + ], + [ + 0.0, + 29.000000000000075, + 37.000000000000064, + 1.0 + ] + ], + "grounded": false + } + ], + "assembly": { + "joints": [ + { + "id": 1, + "name": "Hinge", + "kind": "revolute", + "a": { + "occurrence": 2, + "snap": { + "type": "circle_center", + "pick": [ + 1.0, + 37.0, + 4.0 + ] + } + }, + "b": { + "occurrence": 6, + "snap": { + "type": "circle_center", + "pick": [ + 1.0, + 37.0, + 4.0 + ] + } + }, + "values": [ + 1.5707963267948966 + ], + "limits": [ + null + ], + "offset": 0.0, + "angle": 0.0 + } + ] + } +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/corpus/m0-box-fillet-hole.expected.json b/apps/solvecraft-cli/tests/corpus/m0-box-fillet-hole.expected.json new file mode 100644 index 00000000..75a15fee --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/m0-box-fillet-hole.expected.json @@ -0,0 +1,128 @@ +{ + "bodies": [ + { + "area_mm2": 5641.5699696719, + "bbox": { + "max": [ + 40.0, + 30.0, + 20.00000000000001 + ], + "min": [ + 0.0, + 0.0, + -2.331468351865795e-15 + ] + }, + "center_of_mass": [ + 20.0333953680467, + 15.02475683626249, + 9.999999936169297 + ], + "edges": 17, + "face_types": { + "cylinder": 2, + "plane": 6 + }, + "faces": 8, + "kernel": { + "edges": 21, + "faces": 9, + "shells": 1, + "vertices": 14 + }, + "name": "Body1", + "vertices": 12, + "volume_mm3": 22391.21832759239 + } + ], + "body_count": 1, + "params": [ + { + "comment": "", + "error": null, + "expression": "40 mm", + "model": false, + "name": "width", + "unit": "mm", + "value": 40.0 + } + ], + "sketches": [ + { + "curves": 4, + "dof": 4, + "id": 1, + "name": "Sketch1", + "profiles": 1, + "status": "solved" + }, + { + "curves": 1, + "dof": 3, + "id": 4, + "name": "Sketch2", + "profiles": 1, + "status": "solved" + } + ], + "timeline": [ + { + "error": null, + "id": 1, + "ms": 0.043782999999999996, + "name": "Sketch1", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "error": null, + "id": 2, + "ms": 1.609402, + "name": "Extrude1", + "rolled_back": false, + "suppressed": false, + "type": "ExtrudeFeature", + "warning": null + }, + { + "error": null, + "id": 3, + "ms": 11.196359, + "name": "Fillet1", + "rolled_back": false, + "suppressed": false, + "type": "FilletFeature", + "warning": null + }, + { + "error": null, + "id": 4, + "ms": 0.031249, + "name": "Sketch2", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "error": null, + "id": 5, + "ms": 1378.231746, + "name": "Extrude2", + "rolled_back": false, + "suppressed": false, + "type": "ExtrudeFeature", + "warning": null + } + ], + "total": { + "area_mm2": 5641.5699696719, + "edges": 17, + "faces": 8, + "vertices": 12, + "volume_mm3": 22391.21832759239 + } +} diff --git a/apps/solvecraft-cli/tests/corpus/m0-box-fillet-hole.solvecraft b/apps/solvecraft-cli/tests/corpus/m0-box-fillet-hole.solvecraft new file mode 100644 index 00000000..e917f445 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/m0-box-fillet-hole.solvecraft @@ -0,0 +1,208 @@ +{ + "format": "solvecraft/1", + "name": "Untitled", + "units": "mm", + "params": [ + { + "name": "width", + "expr": "40 mm", + "unit": "mm", + "model": false + } + ], + "features": [ + { + "id": 1, + "name": "Sketch1", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "origin", + "name": "XY" + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "p1", + "pos": [ + 0.0, + 0.0 + ], + "fixed": false + }, + { + "id": "p2", + "pos": [ + 40.0, + 0.0 + ], + "fixed": false + }, + { + "id": "p3", + "pos": [ + 40.0, + 30.0 + ], + "fixed": false + }, + { + "id": "p4", + "pos": [ + 0.0, + 30.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "l5", + "type": "line", + "a": 1, + "b": 2, + "construction": false + }, + { + "id": "l6", + "type": "line", + "a": 2, + "b": 3, + "construction": false + }, + { + "id": "l7", + "type": "line", + "a": 3, + "b": 4, + "construction": false + }, + { + "id": "l8", + "type": "line", + "a": 4, + "b": 1, + "construction": false + } + ], + "constraints": [ + { + "id": "k9", + "type": "horizontal", + "l": 0 + }, + { + "id": "k10", + "type": "horizontal", + "l": 2 + }, + { + "id": "k11", + "type": "vertical", + "l": 1 + }, + { + "id": "k12", + "type": "vertical", + "l": 3 + } + ], + "next_id": 13 + } + }, + { + "id": 2, + "name": "Extrude1", + "suppressed": false, + "type": "extrude", + "sketch": 1, + "profiles": { + "type": "all" + }, + "extent": { + "distance": "width / 2", + "direction": "positive" + }, + "operation": "new_body" + }, + { + "id": 3, + "name": "Fillet1", + "suppressed": false, + "type": "fillet", + "edges": [ + [ + 0.0, + 0.0, + 10.0 + ] + ], + "radius": "3" + }, + { + "id": 4, + "name": "Sketch2", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "origin", + "name": "XY" + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "c1.center", + "pos": [ + 20.0, + 15.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "c1", + "type": "circle", + "c": 1, + "r": 5.0, + "construction": false + } + ], + "constraints": [], + "next_id": 2 + } + }, + { + "id": 5, + "name": "Extrude2", + "suppressed": false, + "type": "extrude", + "sketch": 4, + "profiles": { + "type": "all" + }, + "extent": { + "distance": "20", + "direction": "positive" + }, + "operation": "cut" + } + ], + "next_id": 6 +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/corpus/m0-revolve-primitives.expected.json b/apps/solvecraft-cli/tests/corpus/m0-revolve-primitives.expected.json new file mode 100644 index 00000000..3c5e4e11 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/m0-revolve-primitives.expected.json @@ -0,0 +1,155 @@ +{ + "bodies": [ + { + "area_mm2": 835.4130709318358, + "bbox": { + "max": [ + 15.0, + 15.0, + 5.0 + ], + "min": [ + -15.0, + 0.0, + 0.0 + ] + }, + "center_of_mass": [ + -5.1971813422033816e-8, + 8.063488509334698, + 2.500000003353729 + ], + "edges": 12, + "face_types": { + "cylinder": 2, + "plane": 4 + }, + "faces": 6, + "kernel": { + "edges": 28, + "faces": 14, + "shells": 1, + "vertices": 16 + }, + "name": "Body1", + "vertices": 8, + "volume_mm3": 981.8090960455798 + }, + { + "area_mm2": 693.6702600465544, + "bbox": { + "max": [ + 40.0, + 10.0, + 10.000000000000007 + ], + "min": [ + 30.0, + 0.0, + -2.3775115272352145e-15 + ] + }, + "center_of_mass": [ + 35.02683928802334, + 4.999999999290639, + 4.973160725428021 + ], + "edges": 17, + "face_types": { + "cylinder": 1, + "plane": 7 + }, + "faces": 8, + "kernel": { + "edges": 21, + "faces": 9, + "shells": 1, + "vertices": 14 + }, + "name": "Body2", + "vertices": 12, + "volume_mm3": 869.3724925404262 + } + ], + "body_count": 2, + "params": [], + "sketches": [ + { + "curves": 4, + "dof": 8, + "id": 1, + "name": "Sketch1", + "profiles": 1, + "status": "solved" + } + ], + "timeline": [ + { + "error": null, + "id": 1, + "ms": 0.034476, + "name": "Sketch1", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "error": null, + "id": 2, + "ms": 7.732595999999999, + "name": "Revolve1", + "rolled_back": false, + "suppressed": false, + "type": "RevolveFeature", + "warning": null + }, + { + "error": null, + "id": 3, + "ms": 2.5985959999999997, + "name": "Box1", + "rolled_back": false, + "suppressed": false, + "type": "BoxFeature", + "warning": null + }, + { + "error": null, + "id": 4, + "ms": 1.6926910000000002, + "name": "Cylinder1", + "rolled_back": false, + "suppressed": false, + "type": "CylinderFeature", + "warning": null + }, + { + "error": null, + "id": 5, + "ms": 384.534019, + "name": "Combine1", + "rolled_back": false, + "suppressed": false, + "type": "CombineFeature", + "warning": null + }, + { + "error": null, + "id": 6, + "ms": 150.763588, + "name": "Chamfer1", + "rolled_back": false, + "suppressed": false, + "type": "ChamferFeature", + "warning": null + } + ], + "total": { + "area_mm2": 1529.0833309783902, + "edges": 29, + "faces": 14, + "vertices": 20, + "volume_mm3": 1851.181588586006 + } +} diff --git a/apps/solvecraft-cli/tests/corpus/m0-revolve-primitives.solvecraft b/apps/solvecraft-cli/tests/corpus/m0-revolve-primitives.solvecraft new file mode 100644 index 00000000..bcfcc0b4 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/m0-revolve-primitives.solvecraft @@ -0,0 +1,171 @@ +{ + "format": "solvecraft/1", + "name": "Untitled", + "units": "mm", + "params": [], + "features": [ + { + "id": 1, + "name": "Sketch1", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "origin", + "name": "XZ" + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "l1.start", + "pos": [ + 10.0, + 0.0 + ], + "fixed": false + }, + { + "id": "l1.end", + "pos": [ + 15.0, + 0.0 + ], + "fixed": false + }, + { + "id": "l2.end", + "pos": [ + 15.0, + 5.0 + ], + "fixed": false + }, + { + "id": "l3.end", + "pos": [ + 10.0, + 5.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "l1", + "type": "line", + "a": 1, + "b": 2, + "construction": false + }, + { + "id": "l2", + "type": "line", + "a": 2, + "b": 3, + "construction": false + }, + { + "id": "l3", + "type": "line", + "a": 3, + "b": 4, + "construction": false + }, + { + "id": "l4", + "type": "line", + "a": 4, + "b": 1, + "construction": false + } + ], + "constraints": [], + "next_id": 5 + } + }, + { + "id": 2, + "name": "Revolve1", + "suppressed": false, + "type": "revolve", + "sketch": 1, + "profiles": { + "type": "all" + }, + "axis": { + "type": "sketch_axis", + "axis": "y" + }, + "angle": "180 deg", + "operation": "new_body" + }, + { + "id": 3, + "name": "Box1", + "suppressed": false, + "type": "box", + "corner": [ + 30.0, + 0.0, + 0.0 + ], + "length": "10", + "width": "10", + "height": "10", + "operation": "new_body" + }, + { + "id": 4, + "name": "Cylinder1", + "suppressed": false, + "type": "cylinder", + "base": [ + 35.0, + 5.0, + -5.0 + ], + "axis": [ + 0.0, + 0.0, + 1.0 + ], + "radius": "(4) / 2", + "height": "20", + "operation": "new_body" + }, + { + "id": 5, + "name": "Combine1", + "suppressed": false, + "type": "combine", + "target": "Body2", + "tools": [ + "Body3" + ], + "operation": "cut", + "keep_tools": false + }, + { + "id": 6, + "name": "Chamfer1", + "suppressed": false, + "type": "chamfer", + "edges": [ + [ + 30.0, + 5.0, + 10.0 + ] + ], + "distance": "1" + } + ], + "next_id": 7 +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/corpus/modern-path-pattern.expected.json b/apps/solvecraft-cli/tests/corpus/modern-path-pattern.expected.json new file mode 100644 index 00000000..19a67614 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/modern-path-pattern.expected.json @@ -0,0 +1,122 @@ +{ + "bodies": [ + { + "area_mm2": 16463.932136252846, + "bbox": { + "max": [ + 120.0, + 60.0, + 5.0000000000000036 + ], + "min": [ + 0.0, + 0.0, + 0.0 + ] + }, + "center_of_mass": [ + 60.000000010005735, + 29.965234604788655, + 2.4999999999999867 + ], + "density_g_cm3": 1.29, + "edges": 26, + "face_types": { + "cylinder": 7, + "plane": 6 + }, + "faces": 13, + "kernel": { + "edges": 54, + "faces": 20, + "shells": 1, + "vertices": 36 + }, + "mass_g": 45.163625870882676, + "material": "Default", + "name": "Plate", + "vertices": 22, + "volume_mm3": 35010.56269060673 + } + ], + "body_count": 1, + "params": [], + "sketches": [ + { + "curves": 4, + "dof": 8, + "id": 1, + "name": "Plate_sketch", + "profiles": 1, + "status": "solved" + }, + { + "curves": 2, + "dof": 6, + "id": 3, + "name": "PathSketch", + "profiles": 1, + "status": "solved" + } + ], + "timeline": [ + { + "error": null, + "id": 1, + "ms": 0.093199, + "name": "Plate_sketch", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "error": null, + "id": 2, + "ms": 9.166392, + "name": "Plate", + "rolled_back": false, + "suppressed": false, + "type": "ExtrudeFeature", + "warning": null + }, + { + "error": null, + "id": 3, + "ms": 0.057569999999999996, + "name": "PathSketch", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "error": null, + "id": 4, + "ms": 84.237259, + "name": "Hole1", + "rolled_back": false, + "suppressed": false, + "type": "ExtrudeFeature", + "warning": null + }, + { + "error": null, + "id": 5, + "ms": 1093.817516, + "name": "PathPattern1", + "rolled_back": false, + "suppressed": false, + "type": "PathPatternFeature", + "warning": null + } + ], + "total": { + "area_mm2": 16463.932136252846, + "edges": 26, + "faces": 13, + "mass_g": 45.163625870882676, + "vertices": 22, + "volume_mm3": 35010.56269060673 + } +} diff --git a/apps/solvecraft-cli/tests/corpus/modern-path-pattern.solvecraft b/apps/solvecraft-cli/tests/corpus/modern-path-pattern.solvecraft new file mode 100644 index 00000000..f7e882f3 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/modern-path-pattern.solvecraft @@ -0,0 +1,238 @@ +{ + "format": "solvecraft/1", + "name": "Untitled", + "units": "mm", + "params": [], + "features": [ + { + "id": 1, + "name": "Plate_sketch", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "origin", + "name": "XY" + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "l1.start", + "pos": [ + 0.0, + 0.0 + ], + "fixed": false + }, + { + "id": "l1.end", + "pos": [ + 120.0, + 0.0 + ], + "fixed": false + }, + { + "id": "l2.end", + "pos": [ + 120.0, + 60.0 + ], + "fixed": false + }, + { + "id": "l3.end", + "pos": [ + 0.0, + 60.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "l1", + "type": "line", + "a": 1, + "b": 2, + "construction": false + }, + { + "id": "l2", + "type": "line", + "a": 2, + "b": 3, + "construction": false + }, + { + "id": "l3", + "type": "line", + "a": 3, + "b": 4, + "construction": false + }, + { + "id": "l4", + "type": "line", + "a": 4, + "b": 1, + "construction": false + } + ], + "constraints": [], + "counters": {} + } + }, + { + "id": 2, + "name": "Plate", + "suppressed": false, + "body_names": [ + "Plate" + ], + "param_names": [ + "d1" + ], + "type": "extrude", + "sketch": 1, + "profiles": { + "type": "curves", + "loops": [ + [ + "l1", + "l2", + "l3", + "l4" + ] + ] + }, + "extent": { + "distance": "5", + "direction": "positive" + }, + "operation": "new_body" + }, + { + "id": 3, + "name": "PathSketch", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "origin", + "name": "XY" + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "a1.center", + "pos": [ + 60.0, + 3.3333333333333335 + ], + "fixed": false + }, + { + "id": "a1.start", + "pos": [ + 100.0, + 15.0 + ], + "fixed": false + }, + { + "id": "a1.end", + "pos": [ + 20.0, + 15.000000000000002 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "a1", + "type": "arc", + "c": 1, + "a": 2, + "b": 3, + "construction": false + }, + { + "id": "c1", + "type": "circle", + "c": 2, + "r": 3.0, + "construction": false + } + ], + "constraints": [], + "counters": {} + } + }, + { + "id": 4, + "name": "Hole1", + "suppressed": false, + "body_names": [ + "Plate" + ], + "param_names": [ + "d2" + ], + "type": "extrude", + "sketch": 3, + "profiles": { + "type": "curves", + "loops": [ + [ + "c1" + ] + ] + }, + "extent": { + "distance": "0", + "direction": "positive", + "through_all": true + }, + "operation": "cut" + }, + { + "id": 5, + "name": "PathPattern1", + "suppressed": false, + "param_names": [ + "d3", + "d4" + ], + "type": "pattern", + "features": [ + "Hole1" + ], + "pattern": { + "type": "path", + "path_sketch": 3, + "path": [ + "a1" + ], + "count": "7", + "spacing": "17.875031" + } + } + ], + "next_id": 6 +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/corpus/modern-plastic.expected.json b/apps/solvecraft-cli/tests/corpus/modern-plastic.expected.json new file mode 100644 index 00000000..407ac07d --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/modern-plastic.expected.json @@ -0,0 +1,126 @@ +{ + "bodies": [ + { + "area_mm2": 24280.380636378435, + "bbox": { + "max": [ + 80.0, + 50.0, + 32.0 + ], + "min": [ + 0.0, + 0.0, + 0.0 + ] + }, + "center_of_mass": [ + 39.89510389356416, + 24.906013036240918, + 10.936558895792075 + ], + "density_g_cm3": 1.29, + "edges": 82, + "face_types": { + "cone": 1, + "cylinder": 1, + "plane": 35 + }, + "faces": 37, + "kernel": { + "edges": 90, + "faces": 39, + "shells": 1, + "vertices": 60 + }, + "mass_g": 29.716687268283682, + "material": "Default", + "name": "Body1", + "vertices": 56, + "volume_mm3": 23036.19168084006 + } + ], + "body_count": 1, + "params": [ + { + "comment": "", + "error": null, + "expression": "2 mm", + "model": false, + "name": "wall", + "unit": "mm", + "value": 2.0 + } + ], + "sketches": [], + "timeline": [ + { + "error": null, + "id": 1, + "ms": 13.671479, + "name": "Box1", + "rolled_back": false, + "suppressed": false, + "type": "BoxFeature", + "warning": null + }, + { + "error": null, + "id": 2, + "ms": 15.729548999999999, + "name": "Shell1", + "rolled_back": false, + "suppressed": false, + "type": "ShellFeature", + "warning": null + }, + { + "error": null, + "id": 3, + "ms": 136.407014, + "name": "Lip1", + "rolled_back": false, + "suppressed": false, + "type": "LipFeature", + "warning": null + }, + { + "error": null, + "id": 4, + "ms": 530.869306, + "name": "Boss1", + "rolled_back": false, + "suppressed": false, + "type": "BossFeature", + "warning": null + }, + { + "error": null, + "id": 5, + "ms": 176.568115, + "name": "Rest1", + "rolled_back": false, + "suppressed": false, + "type": "RestFeature", + "warning": null + }, + { + "error": null, + "id": 6, + "ms": 260.395779, + "name": "SnapFit1", + "rolled_back": false, + "suppressed": false, + "type": "SnapFitFeature", + "warning": null + } + ], + "total": { + "area_mm2": 24280.380636378435, + "edges": 82, + "faces": 37, + "mass_g": 29.716687268283682, + "vertices": 56, + "volume_mm3": 23036.19168084006 + } +} diff --git a/apps/solvecraft-cli/tests/corpus/modern-plastic.solvecraft b/apps/solvecraft-cli/tests/corpus/modern-plastic.solvecraft new file mode 100644 index 00000000..fc8a96c1 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/modern-plastic.solvecraft @@ -0,0 +1,158 @@ +{ + "format": "solvecraft/1", + "name": "Untitled", + "units": "mm", + "params": [ + { + "name": "wall", + "expr": "2 mm", + "unit": "mm", + "model": false + } + ], + "features": [ + { + "id": 1, + "name": "Box1", + "suppressed": false, + "param_names": [ + "d1", + "d2", + "d3" + ], + "type": "box", + "corner": [ + 0.0, + 0.0, + 0.0 + ], + "length": "80", + "width": "50", + "height": "30", + "operation": "new_body" + }, + { + "id": 2, + "name": "Shell1", + "suppressed": false, + "param_names": [ + "d4" + ], + "type": "shell", + "faces": [ + [ + 40.0, + 25.0, + 30.0 + ] + ], + "thickness": "wall" + }, + { + "id": 3, + "name": "Lip1", + "suppressed": false, + "param_names": [ + "d5", + "d6" + ], + "type": "lip", + "face": [ + 1.0, + 25.0, + 30.0 + ], + "width": "1", + "height": "2" + }, + { + "id": 4, + "name": "Boss1", + "suppressed": false, + "param_names": [ + "d7", + "d8", + "d9", + "d10" + ], + "type": "boss", + "position": [ + 20.0, + 20.0, + 2.0 + ], + "direction": [ + 0.0, + 0.0, + 1.0 + ], + "diameter": "6", + "height": "10", + "hole_diameter": "2.5", + "draft": "1 deg" + }, + { + "id": 5, + "name": "Rest1", + "suppressed": false, + "param_names": [ + "d11", + "d12", + "d13", + "d14" + ], + "type": "rest", + "position": [ + 60.0, + 30.0, + 2.0 + ], + "direction": [ + 0.0, + 0.0, + 1.0 + ], + "width": "8", + "length": "12", + "height": "3", + "thickness": "1" + }, + { + "id": 6, + "name": "SnapFit1", + "suppressed": false, + "param_names": [ + "d15", + "d16", + "d17", + "d18", + "d19" + ], + "type": "snap_fit", + "position": [ + 40.0, + 12.0, + 2.0 + ], + "direction": [ + 0.0, + 0.0, + 1.0 + ], + "hook": [ + 0.0, + 1.0, + 0.0 + ], + "length": "12", + "thickness": "1.5", + "width": "6", + "catch_depth": "1", + "catch_length": "2" + } + ], + "next_id": 7, + "plastic": { + "active": "ABS (1.5mm)" + } +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/corpus/modern-sheet-metal.expected.json b/apps/solvecraft-cli/tests/corpus/modern-sheet-metal.expected.json new file mode 100644 index 00000000..52a67a4b --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/modern-sheet-metal.expected.json @@ -0,0 +1,85 @@ +{ + "bodies": [ + { + "area_mm2": 10411.967792188118, + "bbox": { + "max": [ + 70.0, + 50.00000000000002, + 32.500000000000014 + ], + "min": [ + 0.0, + -4.440892098500626e-14, + -5.329070518200751e-15 + ] + }, + "center_of_mass": [ + 36.03765771659564, + 24.999999999999982, + 15.08756759282672 + ], + "density_g_cm3": 1.29, + "edges": 44, + "face_types": { + "cylinder": 4, + "plane": 10 + }, + "faces": 22, + "kernel": { + "edges": 44, + "faces": 22, + "shells": 1, + "vertices": 24 + }, + "mass_g": 15.605746095907175, + "material": "Default", + "name": "Body1", + "sheet_metal": true, + "vertices": 24, + "volume_mm3": 12097.477593726493 + } + ], + "body_count": 1, + "params": [], + "sketches": [ + { + "curves": 3, + "dof": 8, + "id": 1, + "name": "Contour", + "profiles": 0, + "status": "solved" + } + ], + "timeline": [ + { + "error": null, + "id": 1, + "ms": 0.074984, + "name": "Contour", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "error": null, + "id": 2, + "ms": 37.187197, + "name": "ContourFlange", + "rolled_back": false, + "suppressed": false, + "type": "ContourFlangeFeature", + "warning": null + } + ], + "total": { + "area_mm2": 10411.967792188118, + "edges": 44, + "faces": 22, + "mass_g": 15.605746095907175, + "vertices": 24, + "volume_mm3": 12097.477593726493 + } +} diff --git a/apps/solvecraft-cli/tests/corpus/modern-sheet-metal.solvecraft b/apps/solvecraft-cli/tests/corpus/modern-sheet-metal.solvecraft new file mode 100644 index 00000000..b9611c82 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/modern-sheet-metal.solvecraft @@ -0,0 +1,120 @@ +{ + "format": "solvecraft/1", + "name": "Untitled", + "units": "mm", + "params": [], + "features": [ + { + "id": 1, + "name": "Contour", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "custom", + "plane": { + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ] + } + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "l1.start", + "pos": [ + 0.0, + 0.0 + ], + "fixed": false + }, + { + "id": "l1.end", + "pos": [ + 40.0, + 0.0 + ], + "fixed": false + }, + { + "id": "l2.end", + "pos": [ + 40.0, + -30.0 + ], + "fixed": false + }, + { + "id": "l3.end", + "pos": [ + 70.0, + -30.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "l1", + "type": "line", + "a": 1, + "b": 2, + "construction": false + }, + { + "id": "l2", + "type": "line", + "a": 2, + "b": 3, + "construction": false + }, + { + "id": "l3", + "type": "line", + "a": 3, + "b": 4, + "construction": false + } + ], + "constraints": [], + "counters": {} + } + }, + { + "id": 2, + "name": "ContourFlange", + "suppressed": false, + "param_names": [ + "d1" + ], + "type": "sheet_contour", + "sketch": 1, + "curves": [ + "l1", + "l2", + "l3" + ], + "distance": "50" + } + ], + "next_id": 3 +} \ No newline at end of file diff --git a/apps/solvecraft-cli/tests/corpus/scripts/components-arm.json b/apps/solvecraft-cli/tests/corpus/scripts/components-arm.json new file mode 100644 index 00000000..d3235d00 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/scripts/components-arm.json @@ -0,0 +1,15 @@ +{"commands": [ + {"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}}, + {"command": "FusionCreateNewComponentCommand", "params": {"name": "Arm"}}, + {"command": "PrimitiveBox", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}}, + {"command": "FusionFilletEdgesCommand", "params": {"edges": [[25, 4, 4]], "radius": 1}}, + {"command": "component.activate", "params": {"component": 0}}, + {"command": "occurrence.move", "params": {"occurrence": 3, "translate": [0, 50, 0]}}, + {"command": "occurrence.copy", "params": {"component": "Arm", "translate": [0, 20, 0]}}, + {"command": "component.paste_new", "params": {"component": "Arm", "translate": [0, -30, 0]}}, + {"command": "occurrence.ground", "params": {"occurrence": 3}}, + {"command": "SketchCreate", "params": {"plane": "XY", "name": "S1"}}, + {"command": "CircleCenterRadius", "params": {"center": [5, 5], "radius": 2}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": 10, "operation": "cut"}} +]} diff --git a/apps/solvecraft-cli/tests/corpus/scripts/joints-hinge.json b/apps/solvecraft-cli/tests/corpus/scripts/joints-hinge.json new file mode 100644 index 00000000..37467809 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/scripts/joints-hinge.json @@ -0,0 +1,14 @@ +{"commands": [ + {"command": "ChangeParameterCommand", "params": {"name": "open_angle", "expression": "90 deg"}}, + {"command": "FusionCreateNewComponentCommand", "params": {"name": "LeafA"}}, + {"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}}, + {"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}}, + {"command": "component.activate", "params": {"component": "root"}}, + {"command": "FusionCreateNewComponentCommand", "params": {"name": "LeafB"}}, + {"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}}, + {"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}}, + {"command": "component.activate", "params": {"component": "root"}}, + {"command": "occurrence.ground", "params": {"occurrence": 2, "grounded": true}}, + {"command": "JointAssembleCmdNew", "params": {"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}}, + {"command": "FusionMoveJointsCommand", "params": {"joint": "Hinge", "value": "open_angle"}} +]} diff --git a/apps/solvecraft-cli/tests/corpus/scripts/m0-box-fillet-hole.json b/apps/solvecraft-cli/tests/corpus/scripts/m0-box-fillet-hole.json new file mode 100644 index 00000000..281834c1 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/scripts/m0-box-fillet-hole.json @@ -0,0 +1,12 @@ +{"commands": [ + {"command": "ChangeParameterCommand", "params": {"name": "width", "expression": "40 mm"}}, + {"command": "SketchCreate", "params": {"plane": "XY"}}, + {"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [40, 30]}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": "width / 2"}}, + {"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 10]], "radius": 3}}, + {"command": "SketchCreate", "params": {"plane": "XY"}}, + {"command": "CircleCenterRadius", "params": {"center": [20, 15], "radius": 5}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": 20, "operation": "cut"}} +]} diff --git a/apps/solvecraft-cli/tests/corpus/scripts/m0-revolve-primitives.json b/apps/solvecraft-cli/tests/corpus/scripts/m0-revolve-primitives.json new file mode 100644 index 00000000..56fd5c90 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/scripts/m0-revolve-primitives.json @@ -0,0 +1,11 @@ +{"commands": [ + {"command": "SketchCreate", "params": {"plane": "XZ"}}, + {"command": "DrawPolyline", "params": {"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}}, + {"command": "SketchStop", "params": {}}, + {"command": "Revolve", "params": {"axis": "y"}}, + {"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}}, + {"command": "PrimitiveCylinder", "params": {"diameter": 4, "height": 20, "base": [35, 5, -5]}}, + {"command": "FusionCombineCommand", "params": {"target": "Body2", "tools": ["Body3"], "operation": "cut"}}, + {"command": "FusionChamferCommand", "params": {"edges": [[30, 5, 10]], "distance": 1}}, + {"command": "timeline.edit", "params": {"feature": "Revolve1", "set": {"angle": "180 deg"}}} +]} diff --git a/apps/solvecraft-cli/tests/corpus/scripts/modern-path-pattern.json b/apps/solvecraft-cli/tests/corpus/scripts/modern-path-pattern.json new file mode 100644 index 00000000..fbbfdb82 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/scripts/modern-path-pattern.json @@ -0,0 +1,197 @@ +{ + "commands": [ + { + "command": "SketchCreate", + "params": { + "name": "Plate_sketch", + "plane": "XY" + } + }, + { + "command": "DrawPolyline", + "params": { + "construction": false, + "ids": [ + "l1" + ], + "points": [ + [ + 0, + 0 + ], + [ + 120, + 0 + ] + ] + } + }, + { + "command": "DrawPolyline", + "params": { + "construction": false, + "ids": [ + "l2" + ], + "points": [ + "l1.end", + [ + 120, + 60 + ] + ] + } + }, + { + "command": "DrawPolyline", + "params": { + "construction": false, + "ids": [ + "l3" + ], + "points": [ + "l2.end", + [ + 0, + 60 + ] + ] + } + }, + { + "command": "DrawPolyline", + "params": { + "construction": false, + "ids": [ + "l4" + ], + "points": [ + "l3.end", + "l1.start" + ] + } + }, + { + "command": "SketchStop", + "params": {} + }, + { + "command": "Extrude", + "params": { + "body_names": [ + "Plate" + ], + "direction": "positive", + "distance": 5, + "name": "Plate", + "operation": "new", + "profiles": [ + { + "area_mm2": 7200.0, + "centroid_sketch": [ + 60.0, + 30.0 + ], + "loops": [ + { + "curves": [ + "l1", + "l2", + "l3", + "l4" + ], + "outer": true + } + ], + "sketch": "Plate_sketch" + } + ], + "sketch": "Plate_sketch" + } + }, + { + "command": "SketchCreate", + "params": { + "name": "PathSketch", + "plane": "XY" + } + }, + { + "command": "ArcThreePoint", + "params": { + "end": [ + 20, + 15 + ], + "id": "a1", + "start": [ + 100, + 15 + ], + "through": [ + 60, + 45 + ] + } + }, + { + "command": "CircleCenterRadius", + "params": { + "center": "a1.start", + "construction": false, + "id": "c1", + "radius": 3 + } + }, + { + "command": "SketchStop", + "params": {} + }, + { + "command": "Extrude", + "params": { + "body_names": [ + "Plate" + ], + "direction": "positive", + "name": "Hole1", + "operation": "cut", + "profiles": [ + { + "area_mm2": 28.274334, + "centroid_sketch": [ + 100.0, + 15.0 + ], + "loops": [ + { + "curves": [ + "c1" + ], + "outer": true + } + ], + "sketch": "PathSketch" + } + ], + "sketch": "PathSketch", + "through_all": true + } + }, + { + "command": "PatternOnPath", + "params": { + "count": 7, + "features": [ + "Hole1" + ], + "name": "PathPattern1", + "path": [ + "a1" + ], + "path_sketch": "PathSketch", + "spacing": 17.875031 + } + } + ] +} diff --git a/apps/solvecraft-cli/tests/corpus/scripts/modern-plastic.json b/apps/solvecraft-cli/tests/corpus/scripts/modern-plastic.json new file mode 100644 index 00000000..08edf91d --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/scripts/modern-plastic.json @@ -0,0 +1,10 @@ +{"commands": [ + {"command": "ChangeParameterCommand", "params": {"name": "wall", "expression": "2 mm"}}, + {"command": "PrimitiveBox", "params": {"length": 80, "width": 50, "height": 30}}, + {"command": "FusionShellBodyCommand", "params": {"faces": [[40, 25, 30]], "thickness": "wall"}}, + {"command": "FusionLipCommand", "params": {"face": [1, 25, 30], "width": 1, "height": 2}}, + {"command": "FusionBossCommand", "params": {"position": [20, 20, 2], "diameter": 6, "height": 10, "hole_diameter": 2.5, "draft": "1 deg"}}, + {"command": "FusionRestCommand", "params": {"position": [60, 30, 2], "width": 8, "length": 12, "height": 3, "thickness": 1}}, + {"command": "FusionSnapFitCommand", "params": {"position": [40, 12, 2], "direction": [0, 0, 1], "hook": [0, 1, 0], "length": 12, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}}, + {"command": "FusionManagePlasticRuleCommand", "params": {"name": "ABS (1.5mm)", "active": true}} +]} diff --git a/apps/solvecraft-cli/tests/corpus/scripts/modern-sheet-metal.json b/apps/solvecraft-cli/tests/corpus/scripts/modern-sheet-metal.json new file mode 100644 index 00000000..1a1af3d1 --- /dev/null +++ b/apps/solvecraft-cli/tests/corpus/scripts/modern-sheet-metal.json @@ -0,0 +1,96 @@ +{ + "commands": [ + { + "command": "SketchCreate", + "params": { + "name": "Contour", + "plane": { + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ] + } + } + }, + { + "command": "DrawPolyline", + "params": { + "construction": false, + "ids": [ + "l1" + ], + "points": [ + [ + 0.0, + 0.0 + ], + [ + 40.0, + 0.0 + ] + ] + } + }, + { + "command": "DrawPolyline", + "params": { + "construction": false, + "ids": [ + "l2" + ], + "points": [ + "l1.end", + [ + 40.0, + -30.0 + ] + ] + } + }, + { + "command": "DrawPolyline", + "params": { + "construction": false, + "ids": [ + "l3" + ], + "points": [ + "l2.end", + [ + 70.0, + -30.0 + ] + ] + } + }, + { + "command": "SketchStop", + "params": {} + }, + { + "command": "FusionSheetMetalFlangeCommand", + "params": { + "curves": [ + "l1", + "l2", + "l3" + ], + "distance": 50, + "name": "ContourFlange", + "sketch": "Contour", + "type": "contour" + } + } + ] +} diff --git a/apps/solvecraft-cli/tests/design_corpus.rs b/apps/solvecraft-cli/tests/design_corpus.rs new file mode 100644 index 00000000..2a904dc7 --- /dev/null +++ b/apps/solvecraft-cli/tests/design_corpus.rs @@ -0,0 +1,211 @@ +//! Designs saved by older SolveCraft builds (tests/corpus, see its README) open and evaluate as +//! they did when they were saved; damaged and hostile design files never panic. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; +use solvecraft_engine::Session; + +fn corpus() -> Vec { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus"); + let mut v: Vec = + std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solvecraft")).collect(); + v.sort(); + assert!(v.len() >= 7, "corpus files in {}", dir.display()); + v +} + +fn open(path: &Path) -> Session { + let mut s = Session::default(); + s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + s +} + +/// Differences that are deliberate behaviour changes made before the file format had versions +/// (no migration can tell those files apart): (file, check, why). +const KNOWN: [(&str, &str, &str); 2] = [ + ( + "m0-revolve-primitives", + "Body1 bbox", + "partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way", + ), + ("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"), +]; + +fn known(file: &str, check: &str) -> bool { + KNOWN.iter().any(|(f, c, _)| *f == file && *c == check) +} + +/// Volumes and areas then came from coarser tessellation (and curved booleans and fillets have +/// become exact since): the same part within this relative tolerance (the oracle's). +const REL: f64 = 1e-3; + +fn close(a: f64, b: f64, rel: f64) -> bool { + (a - b).abs() <= rel * b.abs().max(1.0) +} + +#[test] +fn old_designs_open_and_evaluate_as_saved() { + let mut problems = Vec::new(); + for p in corpus() { + let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string(); + let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap(); + let mut s = open(&p); + for r in &s.model.results { + if let Some(e) = &r.error { + problems.push(format!("{name}: feature {}: {e}", r.id)); + } + } + assert_eq!(s.doc.format, solvecraft_engine::doc::format::format_string(solvecraft_engine::doc::format::FORMAT), "{name}: upgraded"); + let got = s.execute("MeasureCommand", &json!({})).unwrap(); + if got["body_count"] != want["body_count"] && !known(&name, "body_count") { + problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"])); + } + for w in want["bodies"].as_array().into_iter().flatten() { + let bname = w["name"].as_str().unwrap_or_default(); + let Some(g) = got["bodies"].as_array().into_iter().flatten().find(|g| g["name"] == w["name"]) else { + problems.push(format!("{name}: body {bname} missing")); + continue; + }; + for k in ["volume_mm3", "area_mm2"] { + let (a, b) = (g[k].as_f64().unwrap_or(f64::NAN), w[k].as_f64().unwrap_or(f64::NAN)); + if !close(a, b, REL) { + problems.push(format!("{name}: {bname} {k} {a} (saved {b})")); + } + } + if known(&name, &format!("{bname} bbox")) { + continue; + } + for (end, i) in [("min", 0), ("min", 1), ("min", 2), ("max", 0), ("max", 1), ("max", 2)] { + let (a, b) = (g["bbox"][end][i].as_f64().unwrap_or(f64::NAN), w["bbox"][end][i].as_f64().unwrap_or(f64::NAN)); + if (a - b).abs() > 1e-3 * b.abs().max(1.0) { + problems.push(format!("{name}: {bname} bbox {end}[{i}] {a} (saved {b})")); + } + } + } + // Saved again, it reads back the same. + let again = solvecraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(again, *s.doc, "{name}: round trip"); + } + assert!(problems.is_empty(), "{}", problems.join("\n")); +} + +/// A small deterministic generator (xorshift). +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +const HOSTILE: [&str; 14] = + ["0", "-1", "1e308", "-1e308", "1e9", "4294967297", "\"\"", "\"1/0\"", "\"((((1\"", "null", "true", "[]", "{}", "\"nope\""]; + +/// Every JSON value in a document, as a path of keys and indices. +fn paths(v: &Value, at: &mut Vec, out: &mut Vec>) { + out.push(at.clone()); + match v { + Value::Object(m) => { + for (k, x) in m { + at.push(json!(k)); + paths(x, at, out); + at.pop(); + } + } + Value::Array(a) => { + for (i, x) in a.iter().enumerate() { + at.push(json!(i)); + paths(x, at, out); + at.pop(); + } + } + _ => {} + } +} + +fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> { + let mut cur = v; + for k in path { + cur = match k { + Value::String(s) => cur.get_mut(s.as_str())?, + Value::Number(n) => cur.get_mut(n.as_u64()? as usize)?, + _ => return None, + }; + } + Some(cur) +} + +/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure. +fn try_bytes(bytes: &[u8]) { + if let Ok(doc) = solvecraft_engine::io::read_design(bytes) { + let mut s = Session::new(doc); + let _ = s.execute("MeasureCommand", &json!({})); + let _ = s.execute("document.inspect", &json!({})); + } +} + +#[test] +fn damaged_and_hostile_design_files_never_panic() { + let mut rng = Rng(0x5eed_cafe_f00d_d00d); + let mut failures = Vec::new(); + for p in corpus() { + let name = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + let bytes = std::fs::read(&p).unwrap(); + let doc: Value = serde_json::from_slice(&bytes).unwrap(); + let mut all = Vec::new(); + paths(&doc, &mut Vec::new(), &mut all); + let mut cases: Vec<(String, Vec)> = Vec::new(); + // Truncated and bit-flipped files. + for _ in 0..8 { + let cut = rng.below(bytes.len()); + cases.push((format!("truncated at {cut}"), bytes[..cut].to_vec())); + let mut b = bytes.clone(); + let i = rng.below(b.len()); + b[i] ^= 1 << rng.below(8); + cases.push((format!("bit flip at {i}"), b)); + } + // Values replaced by hostile ones, and keys removed. + for _ in 0..25 { + let path = &all[rng.below(all.len())]; + let mut v = doc.clone(); + let h = HOSTILE[rng.below(HOSTILE.len())]; + if let Some(slot) = at_mut(&mut v, path) { + *slot = serde_json::from_str(h).unwrap(); + } + cases.push((format!("{path:?} = {h}"), v.to_string().into_bytes())); + let mut v = doc.clone(); + if let Some((last, parent)) = path.split_last() + && let (Some(Value::Object(m)), Value::String(k)) = (at_mut(&mut v, parent), last) + { + m.remove(k); + } + cases.push((format!("{path:?} removed"), v.to_string().into_bytes())); + } + // Features repeated many times; deep nesting; not JSON at all. + let mut v = doc.clone(); + if let Some(Value::Array(fs)) = v.get_mut("features") { + let copy = fs.clone(); + for _ in 0..2 { + fs.extend(copy.iter().cloned()); + } + } + cases.push(("features repeated".into(), v.to_string().into_bytes())); + cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes())); + cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect())); + for (what, b) in cases { + if std::env::var_os("CORPUS_TRACE").is_some() { + eprintln!("{name}: {what}"); + } + if std::panic::catch_unwind(|| try_bytes(&b)).is_err() { + failures.push(format!("{name}: {what}")); + } + } + } + assert!(failures.is_empty(), "panics:\n{}", failures.join("\n")); +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 984d299f..b21fc725 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1113,7 +1113,7 @@ pub struct BrowserGroup { } fn default_format() -> String { - "solvecraft/1".into() + crate::format::format_string(crate::format::FORMAT) } fn default_units() -> String { "mm".into() @@ -1214,22 +1214,9 @@ impl Document { self.components.iter().find(|c| c.id.to_string() == key || c.name == key).map(|c| c.id) } + /// Read a design file's JSON (any format this build knows; older ones are upgraded). pub fn from_json(s: &str) -> Result { - let mut d: Document = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("document: {e}")))?; - if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS { - return Err(DocError::Invalid("document too large".into())); - } - // Feature inputs get parameter names (designs from before they had them). - let ids: Vec = d.features.iter().map(|f| f.id).collect(); - for id in ids { - d.name_feature_inputs(id); - } - // Designs from before occurrences: every component gets one, in place. - let missing: Vec<(u64, u64)> = - d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect(); - for (c, p) in missing { - d.add_occurrence(c, p, crate::IDENTITY)?; - } + let d = crate::format::read(s)?; if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS { return Err(DocError::Invalid("document too large".into())); } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs new file mode 100644 index 00000000..f86c2914 --- /dev/null +++ b/crates/doc/src/format.rs @@ -0,0 +1,168 @@ +//! The design file format version and the migrations that bring older files up to date. +//! +//! A design file starts with `"format": "solvecraft/N"`. A file of an older format is read and +//! upgraded step by step (N → N+1 → … → current): JSON-level steps first, for changes in the +//! file's shape, then document-level steps. A file of a newer format than this build knows is +//! refused with a message saying so, never guessed at. +//! +//! History: +//! - 1: every design up to 2026-10-08 (format string since M0). Fields added over time all +//! have defaults; designs from before feature input names and before occurrences are filled +//! in on upgrade. +//! - 2: the same shape; files say 2 once they have been upgraded, so later readers can tell. + +use serde_json::Value; + +use crate::{DocError, Document, Result}; + +/// The format this build writes. +pub const FORMAT: u32 = 2; + +pub fn format_string(n: u32) -> String { + format!("solvecraft/{n}") +} + +/// The version in a format string (`solvecraft/N`). +pub fn version_of(s: &str) -> Option { + s.strip_prefix("solvecraft/")?.parse().ok() +} + +/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. +type JsonStep = fn(&mut Value) -> Result<()>; +const JSON_STEPS: [JsonStep; 1] = [|_| Ok(())]; + +/// Read a design file's JSON, upgrading older formats. +pub fn read(s: &str) -> Result { + let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolveCraft design: {e}")))?; + let Some(obj) = v.as_object() else { + return Err(DocError::Invalid("not a SolveCraft design (expected a JSON object)".into())); + }; + let version = match obj.get("format") { + None => 1, + Some(Value::String(f)) => version_of(f).ok_or_else(|| DocError::Invalid(format!("not a SolveCraft design (format `{f}`)")))?, + Some(_) => return Err(DocError::Invalid("not a SolveCraft design (format is not a string)".into())), + }; + if version == 0 { + return Err(DocError::Invalid("not a SolveCraft design (format 0)".into())); + } + if version > FORMAT { + return Err(DocError::Invalid(format!( + "this design was saved by a newer SolveCraft (file format {version}; this version reads formats up to {FORMAT}): update SolveCraft to open it" + ))); + } + check_numbers(&v, 0)?; + for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) { + step(&mut v)?; + } + let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?; + if d.features.len() > crate::document::MAX_FEATURES + || d.params.len() > crate::document::MAX_PARAMS + || d.components.len() > crate::document::MAX_FEATURES + { + return Err(DocError::Invalid("document too large".into())); + } + for o in &d.occurrences { + if !rigid(&o.transform) { + return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name))); + } + } + if version < 2 { + v1_to_v2(&mut d)?; + } + d.format = format_string(FORMAT); + Ok(d) +} + +/// Largest coordinate or size a design may hold (mm): a kilometre-scale part is already far +/// outside what the kernel's tolerances serve. +const MAX_REAL: f64 = 1e9; + +/// Every non-integer number in the file is finite and within ±`MAX_REAL` (a damaged or hostile +/// file is refused instead of overflowing the geometry later). +fn check_numbers(v: &Value, depth: usize) -> Result<()> { + if depth > 64 { + return Err(DocError::Invalid("damaged design: nested too deeply".into())); + } + match v { + Value::Number(n) if !(n.is_u64() || n.is_i64()) => { + let x = n.as_f64().unwrap_or(f64::NAN); + if !(x.is_finite() && x.abs() <= MAX_REAL) { + return Err(DocError::Invalid(format!("damaged design: the number {n} is out of range"))); + } + } + Value::Array(a) => a.iter().try_for_each(|x| check_numbers(x, depth + 1))?, + Value::Object(m) => m.values().try_for_each(|x| check_numbers(x, depth + 1))?, + _ => {} + } + Ok(()) +} + +/// A placement: rotation (orthonormal, not mirrored) and a translation in range. +fn rigid(m: &crate::Mat) -> bool { + let row = |i: usize| m.get(i).copied().unwrap_or_default(); + let (x, y, z, t) = (row(0), row(1), row(2), row(3)); + let dot = |a: [f64; 4], b: [f64; 4]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let unit = |a: [f64; 4]| (dot(a, a) - 1.0).abs() < 1e-6; + let det = x[0] * (y[1] * z[2] - y[2] * z[1]) - x[1] * (y[0] * z[2] - y[2] * z[0]) + x[2] * (y[0] * z[1] - y[1] * z[0]); + m.iter().flatten().all(|v| v.is_finite()) + && unit(x) + && unit(y) + && unit(z) + && dot(x, y).abs() < 1e-6 + && dot(y, z).abs() < 1e-6 + && dot(x, z).abs() < 1e-6 + && det > 0.0 + && [x[3], y[3], z[3]].iter().all(|v| v.abs() < 1e-9) + && (t[3] - 1.0).abs() < 1e-9 + && t[..3].iter().all(|v| v.abs() <= MAX_REAL) +} + +/// Format 1 files may predate feature input names and occurrences. +fn v1_to_v2(d: &mut Document) -> Result<()> { + let ids: Vec = d.features.iter().map(|f| f.id).collect(); + for id in ids { + d.name_feature_inputs(id); + } + let missing: Vec<(u64, u64)> = + d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect(); + for (c, p) in missing { + d.add_occurrence(c, p, crate::IDENTITY)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions() { + assert_eq!(version_of("solvecraft/1"), Some(1)); + assert_eq!(version_of("solvecraft/x"), None); + let d = Document::new("A"); + assert_eq!(d.format, format_string(FORMAT)); + let back = read(&d.to_json()).unwrap(); + assert_eq!(back, d); + } + + #[test] + fn older_upgraded_newer_refused_others_rejected() { + let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap(); + v["format"] = Value::from("solvecraft/1"); + assert_eq!(read(&v.to_string()).unwrap().format, format_string(FORMAT)); + v.as_object_mut().unwrap().remove("format"); + assert!(read(&v.to_string()).is_ok(), "a file without a format is format 1"); + v["format"] = Value::from(format_string(FORMAT + 1)); + let e = read(&v.to_string()).unwrap_err().to_string(); + assert!(e.contains("newer SolveCraft"), "{e}"); + for bad in ["solvecraft/0", "acad/3", "solvecraft/-1", "solvecraft/99999999999999999999"] { + v["format"] = Value::from(bad); + assert!(read(&v.to_string()).is_err(), "{bad}"); + } + assert!(read("[]").is_err()); + let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap(); + v["huge"] = Value::from(1e300); + assert!(read(&v.to_string()).is_err(), "out-of-range numbers anywhere"); + assert!(read("{\"format\": 2}").is_err()); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c3ec9de3..0e2202ed 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ mod clipboard; mod document; mod eval; pub mod expr; +pub mod format; pub mod joints; mod params; pub mod plastic;