From 6b4691550781e6e687e8d21c0b154c7b3b69392f Mon Sep 17 00:00:00 2001
From: lillith <35337584+0xlillith@users.noreply.github.com>
Date: Sun, 11 Oct 2026 00:32:48 +0200
Subject: [PATCH 1/9] docs: align testing guide with the CI profile
---
docs/book/src/dev/testing.md | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/docs/book/src/dev/testing.md b/docs/book/src/dev/testing.md
index 1be9377e..2e903b54 100644
--- a/docs/book/src/dev/testing.md
+++ b/docs/book/src/dev/testing.md
@@ -1,7 +1,8 @@
# Test harnesses
-`cargo xtask ci` runs everything a pull request must pass: fmt, clippy with `-D warnings`, the
-tests in release mode, `xtask layers`, `xtask assets` and the wasm build check.
+`cargo xtask ci` runs everything a pull request must pass: fmt, `xtask assets`,
+`xtask licences --check`, `xtask layers`, clippy with `-D warnings`, the tests with the `ci`
+profile and the wasm build check.
| Harness | Where | What it catches |
|---|---|---|
@@ -34,7 +35,14 @@ A new file in `crates/ui-egui/tests/scenarios/` is picked up automatically. To r
before it's fixed, add a `{"pending": "why"}` step; its test is then ignored with that reason, and
the fix removes the step.
-## Release mode
+## CI profile
-CI runs the tests with `--release`, because some truck assertions only exist in debug builds
-and fail on valid geometry. If a test fails only in debug, say so in the PR.
+CI runs the tests with `--profile ci`: light optimisation, line tables, no LTO and debug
+assertions off. Some truck debug assertions fail on valid geometry; the `ci` profile disables
+them while building faster than `--release`. If a test fails only in a plain debug build, say
+so in the PR.
+
+Run `cargo xtask test` to build with this profile and run the test binaries concurrently,
+with doc tests alongside. Arguments after `--` go to each test binary, for example
+`cargo xtask test -- shell`. To run the test binaries sequentially instead, use
+`cargo test --workspace --profile ci`.
From 8c1b1478cedff1fd9e0f3cf1d21ef7d8e33b48e4 Mon Sep 17 00:00:00 2001
From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com>
Date: Sun, 11 Oct 2026 05:33:50 +0200
Subject: [PATCH 2/9] =?UTF-8?q?Joins=20and=20cuts=20of=20round=20pads,=20b?=
=?UTF-8?q?osses=20and=20holes=20across=20a=20floor=20that=20falls=20a=20d?=
=?UTF-8?q?egree=20or=20two=20no=20longer=20fail:=20the=20B-rep=20results?=
=?UTF-8?q?=20were=20right,=20but=20the=20plausibility=20check=20meshed=20?=
=?UTF-8?q?them=20without=20refinement,=20and=20where=20the=20shallow=20cu?=
=?UTF-8?q?t=20trims=20a=20round=20side=20to=20a=20thin=20band=20the=20pla?=
=?UTF-8?q?in=20mesh=20spans=20it=20with=20triangles=20cutting=20through?=
=?UTF-8?q?=20the=20cylinder,=20so=20the=20check=20saw=20missing=20materia?=
=?UTF-8?q?l=20(0.8=20to=201.6=20%=20of=20samples=20misclassified),=20reje?=
=?UTF-8?q?cted=20the=20result,=20and=20the=20planar=20fallback=20then=20r?=
=?UTF-8?q?efused=20the=20round=20tool.=20The=20check=20now=20meshes=20the?=
=?UTF-8?q?=20operands=20and=20the=20result=20with=20refinement=20on=20cur?=
=?UTF-8?q?ved=20faces=20(as=20measure=20does).=20Test:=20round=5Fpads=5Fc?=
=?UTF-8?q?rossing=5Fa=5Fshallow=5Ffall=20(a=201=C2=B0=20fall=20and=20a=20?=
=?UTF-8?q?2=C2=B0=20four-face=20fall=20toward=20a=20drain)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
crates/kernel/src/ops.rs | 9 ++++--
crates/kernel/src/tests.rs | 59 ++++++++++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+), 3 deletions(-)
diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs
index 045f48ba..7b0318a8 100644
--- a/crates/kernel/src/ops.rs
+++ b/crates/kernel/src/ops.rs
@@ -249,9 +249,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> {
let size = a.size().max(b.size());
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
// thin part trimmed by a long one), a step from their size would facet the small faces
- // there coarsely enough to fail the membership test on a correct result.
+ // there coarsely enough to fail the membership test on a correct result. The meshes are
+ // refined: a round side trimmed to a thin band (a short boss crossing a gently sloping
+ // floor) is otherwise spanned by triangles cutting through it, which reads as missing
+ // material.
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
- let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
+ let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok());
let (va, vb) = match &meshes {
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
_ => (volume(a), volume(b)),
@@ -260,7 +263,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result > {
// Volume bounds, then membership against the operands (catches misclassified pieces), on
// one mesh of the result. Returns (plausible, volume).
let check = |r: &Body| -> (bool, f64) {
- let Ok(mr) = r.tessellate(tol_m) else { return (false, f64::NAN) };
+ let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) };
let v = mr.measure().volume;
let bounds = v > 0.0
&& match op {
diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs
index b65c41a5..56a52453 100644
--- a/crates/kernel/src/tests.rs
+++ b/crates/kernel/src/tests.rs
@@ -290,6 +290,65 @@ fn moving_a_body_with_an_intersection_curve_edge_ends() {
assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}");
}
+/// A trough whose floor falls 1° (or 2° toward a drain, in four faces), and short round pads
+/// and bosses crossing the fall. The B-rep results were right, but the plausibility check meshed
+/// them without refinement: on a round side trimmed to a thin band by the shallow cut, the plain
+/// mesh spans the band with triangles cutting through the cylinder, so the check saw missing
+/// material, rejected the result, and the planar fallback refused the round pad.
+#[test]
+fn round_pads_crossing_a_shallow_fall() {
+ let hs = |n: Vec3, p: Vec3| {
+ let n = n.normalized().unwrap();
+ HalfSpace { n, d: n.dot(p) }
+ };
+ let walls = |floor: &[HalfSpace]| {
+ let mut h = vec![
+ hs(Vec3::X, Vec3::new(290.0, 0.0, 0.0)),
+ hs(-Vec3::X, Vec3::new(10.0, 0.0, 0.0)),
+ hs(Vec3::Y, Vec3::new(0.0, 140.0, 0.0)),
+ hs(-Vec3::Y, Vec3::new(0.0, 10.0, 0.0)),
+ hs(Vec3::Z, Vec3::new(0.0, 0.0, 90.0)),
+ ];
+ h.extend_from_slice(floor);
+ convex_polyhedron(&h).unwrap()
+ };
+ let block = box_solid(Vec3::ZERO, Vec3::new(300.0, 150.0, 80.0)).unwrap();
+ // One fall along x at 1°: floor z = 8 + t (x − 10).
+ let t = 1f64.to_radians().tan();
+ let fall = boolean(&block, &walls(&[hs(Vec3::new(t, 0.0, -1.0), Vec3::new(10.0, 0.0, 8.0))]), BoolOp::Cut).unwrap().unwrap();
+ let v0 = measure(&fall).unwrap().volume;
+ for x in [60.0, 100.0] {
+ // A pad from z = 9 to 12 (its bottom just under the floor, its top 2–3 mm above).
+ let pad = cylinder(Vec3::new(x, 75.0, 9.0), Vec3::Z, 15.0, 3.0).unwrap();
+ let u = boolean(&fall, &pad, BoolOp::Union).unwrap().unwrap();
+ // The part of the pad above the floor: the floor under the disc is planar, so its mean
+ // height is the height at the centre (where the floor is below z = 9 everywhere).
+ let floor = |x: f64| 8.0 + t * (x - 10.0);
+ let above = if floor(x - 15.0) >= 9.0 { PI * 225.0 * (12.0 - floor(x)) } else { f64::NAN };
+ let v = measure(&u).unwrap().volume;
+ if above.is_finite() {
+ assert!(rel(v, v0 + above) < 1e-5, "pad at x {x}: {v} vs {}", v0 + above);
+ } else {
+ assert!(v > v0 && v < v0 + PI * 225.0 * 3.0, "pad at x {x}: {v}");
+ }
+ }
+ // Four faces falling 2° toward a drain point, and a pad over a valley edge and a wall.
+ let t2 = 2f64.to_radians().tan();
+ let d = Vec3::new(240.0, 110.0, 8.0);
+ let hopper = walls(&[
+ hs(Vec3::new(t2, 0.0, -1.0), d),
+ hs(Vec3::new(-t2, 0.0, -1.0), d),
+ hs(Vec3::new(0.0, t2, -1.0), d),
+ hs(Vec3::new(0.0, -t2, -1.0), d),
+ ]);
+ let trough = boolean(&block, &hopper, BoolOp::Cut).unwrap().unwrap();
+ let pad = cylinder(Vec3::new(270.0, 70.0, 8.0), Vec3::Z, 25.0, 5.0).unwrap();
+ let u = boolean(&trough, &pad, BoolOp::Union).unwrap().unwrap();
+ // Integrated over the disc (outside the wall at x = 290): about 6517 mm³ above the floor.
+ let gain = measure(&u).unwrap().volume - measure(&trough).unwrap().volume;
+ assert!((gain - 6517.0).abs() < 10.0, "{gain}");
+}
+
#[test]
fn planar_booleans_with_coincident_faces() {
// L-bracket: a wall flush with three sides of its base.
From d71bdb78ece41ac6ca618143aa786386656f00e2 Mon Sep 17 00:00:00 2001
From: bherbruck
Date: Sun, 11 Oct 2026 00:37:26 -0400
Subject: [PATCH 3/9] feat: strengthen modeling, configuration, and assembly
workflows
Preserve linked sketch identities through deterministic face projection and resizing. Fix origin-corner picking, persistent inline navigation, appearance editing, configuration styles, component properties, contact sweeps, sheet exports, analytic STEP offsets, and bounded rolling-ball fit accuracy.
Validated with full cargo xtask ci, native workflow screenshots, repeated enclosure replays, and analytic geometry regressions.
---
ROADMAP.md | 43 ++-
crates/doc/src/config.rs | 91 +++++-
crates/doc/src/format.rs | 66 +++-
crates/doc/src/params.rs | 6 +-
crates/doc/src/project.rs | 5 +-
crates/doc/src/project_order.rs | 290 +++++++++++++++++
crates/engine/src/cmd/browser.rs | 8 +
crates/engine/src/cmd/component.rs | 9 +
crates/engine/src/cmd/component_properties.rs | 142 +++++++++
.../src/cmd/component_properties_tests.rs | 129 ++++++++
crates/engine/src/cmd/config.rs | 189 ++++++++++-
crates/engine/src/cmd/contact_sweep.rs | 251 +++++++++++++++
crates/engine/src/cmd/contact_sweep_tests.rs | 170 ++++++++++
crates/engine/src/cmd/edit.rs | 111 ++++++-
crates/engine/src/cmd/edit_tests.rs | 268 ++++++++++++++++
crates/engine/src/cmd/motion.rs | 65 +++-
crates/engine/src/cmd/motion_tests.rs | 34 ++
crates/engine/src/cmd/sheet.rs | 67 +++-
crates/engine/src/cmd/sheet_tests.rs | 77 +++++
crates/engine/src/cmd/sketch_freeform.rs | 52 ++-
crates/engine/src/cmd/sketch_modify.rs | 28 +-
crates/engine/src/cmd/sketch_modify_tests.rs | 106 ++++++-
crates/engine/src/frames.rs | 8 +-
crates/engine/src/frames_tests.rs | 10 +-
crates/kernel/src/curveblend.rs | 280 +++++++++++++---
crates/kernel/src/curveblend_tests.rs | 194 ++++++++++++
crates/kernel/src/step_in/geom.rs | 70 +++-
crates/kernel/src/step_in/tests.rs | 100 ++++++
crates/sketch/src/link.rs | 172 ++++++++--
crates/sketch/src/link_identity_tests.rs | 158 +++++++++
crates/sketch/src/solver.rs | 49 ++-
crates/ui-egui/src/context_menu.rs | 54 +++-
crates/ui-egui/src/dialogs.rs | 13 +-
crates/ui-egui/src/dialogs_appearance.rs | 299 +++++++++++++++---
crates/ui-egui/src/dialogs_motion.rs | 245 +++++++++++---
crates/ui-egui/src/dialogs_sheet.rs | 16 +-
crates/ui-egui/src/dialogs_sheet_tests.rs | 43 +++
crates/ui-egui/src/menu_tests.rs | 46 ++-
crates/ui-egui/src/sketch_corner_ui_tests.rs | 63 ++++
crates/ui-egui/src/sketch_dims.rs | 59 +++-
crates/ui-egui/src/sketch_dims_tests.rs | 128 ++++++++
crates/ui-egui/src/sketch_tools.rs | 51 ++-
crates/ui-egui/src/tools.rs | 6 +-
.../scenarios/s08_sheet_metal_flat_dxf.json | 10 +-
...3_root_sketch_on_moved_component_face.json | 14 +-
.../scenarios/s60_appearance_overrides.json | 17 +
.../scenarios/s80_configuration_styles.json | 21 ++
docs/assembly-properties.md | 33 ++
docs/book/src/guide/assemblies.md | 9 +
docs/book/src/guide/parameters.md | 6 +-
docs/book/src/guide/sheet-metal.md | 17 +-
docs/configurations.md | 43 +++
docs/contact-sets.md | 34 ++
docs/parity.md | 20 +-
54 files changed, 4179 insertions(+), 316 deletions(-)
create mode 100644 crates/doc/src/project_order.rs
create mode 100644 crates/engine/src/cmd/component_properties.rs
create mode 100644 crates/engine/src/cmd/component_properties_tests.rs
create mode 100644 crates/engine/src/cmd/contact_sweep.rs
create mode 100644 crates/engine/src/cmd/contact_sweep_tests.rs
create mode 100644 crates/engine/src/cmd/edit_tests.rs
create mode 100644 crates/kernel/src/curveblend_tests.rs
create mode 100644 crates/sketch/src/link_identity_tests.rs
create mode 100644 crates/ui-egui/src/sketch_corner_ui_tests.rs
create mode 100644 crates/ui-egui/src/sketch_dims_tests.rs
create mode 100644 crates/ui-egui/tests/scenarios/s60_appearance_overrides.json
create mode 100644 crates/ui-egui/tests/scenarios/s80_configuration_styles.json
create mode 100644 docs/assembly-properties.md
create mode 100644 docs/configurations.md
create mode 100644 docs/contact-sets.md
diff --git a/ROADMAP.md b/ROADMAP.md
index 001165a6..bed9e2f7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,6 +1,6 @@
# SolveCraft roadmap
-Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
+Status as of **2026-10-11**. SolveCraft targets Fusion-style parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
@@ -8,7 +8,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Question | Answer |
|---|---|
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
-| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
+| Command parity (in scope) | **331 / 541 (61%)** in-scope commands; SOLID + SKETCH **186 / 291 (64%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
@@ -48,18 +48,18 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
-| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
+| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
-| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
+| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
-| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
-| 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; Fold (bend along a sketch line) | in progress | 36 |
+| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
+| 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 CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: installers, signing, docs, performance | planned | 16 |
-| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
-| 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 |
+| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Left: configuration rules and component/face style columns | in progress | 4 |
+| 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/7"`, 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
@@ -89,7 +89,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
- product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet,
+ product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
+ and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
+ offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
@@ -97,12 +99,33 @@ headline counts in-scope tabs only and lists the deferred ones separately.
face history (exact through coincident surfaces) is not wired in yet.
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
and Revolve To Object on a curved face cut the tool back to the target's surface.
-- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines.
+- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
+ and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
+ touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
sections ruled.
+- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
+ its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
+ the origin; clicked UI regressions verify geometry, closed profiles and undo.
+- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
+ existing geometry before assigning positions, retaining saved curve IDs and constraints
+ across enumeration changes and resize, including concentric circles. Ambiguous identities
+ retain the last geometry and report a lost reference; permutation matching is bounded.
+- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
+ selecting visited values for replacement. Expression completion retains forward-Tab
+ priority; real keyboard regressions cover rectangles, lines and single-value circles.
+- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
+ distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
+ scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
+ check, not a new Fusion oracle result. Under-resolved fits return an error.
+- Contact driving checks the trajectory between clear endpoints, including linked joints,
+ playback and sparse motion exports. Checks use bounded tessellation samples, not exact
+ continuous collision detection; local thin walls and small details remain limitations.
+ See [contact-set limits and recovery](docs/contact-sets.md).
+
## Kernel
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs
index 88c3941d..ffd465e4 100644
--- a/crates/doc/src/config.rs
+++ b/crates/doc/src/config.rs
@@ -1,6 +1,6 @@
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
-//! (its expression per row) or a feature (suppressed or not per row); activating a row sets
-//! those values in the design, which then rebuilds as usual.
+//! expression, feature suppression, body material or body appearance. Activating a row sets
+//! those values in the design atomically, which then rebuilds as usual.
use serde::{Deserialize, Serialize};
@@ -13,12 +13,16 @@ pub enum Column {
Param(String),
/// A feature's suppression (by feature id).
Suppress(u64),
+ /// A body's physical material (library name, or Default to clear).
+ Material(String),
+ /// A body's appearance (library name, serialized Look, or empty to clear).
+ Appearance(String),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ConfigRow {
pub name: String,
- /// One cell per column: an expression, or "true"/"false" for suppression.
+ /// One cell per column: expression, suppression, material name or appearance look.
pub cells: Vec,
}
@@ -39,28 +43,69 @@ impl ConfigTable {
}
}
+/// Parse an appearance cell. Empty/Default follows the body's material; named library looks
+/// and JSON Look objects support both convenient editing and lossless custom colours.
+pub fn appearance_cell(value: &str) -> Result> {
+ let value = value.trim();
+ if value.is_empty() || value.eq_ignore_ascii_case("default") {
+ return Ok(None);
+ }
+ if value.len() > 4096 {
+ return Err(DocError::Invalid("appearance value too long".into()));
+ }
+ if let Some(look) = crate::appearance::library(value) {
+ return Ok(Some(look));
+ }
+ let look: crate::appearance::Look =
+ serde_json::from_str(value).map_err(|_| DocError::Invalid(format!("unknown configuration appearance `{value}`")))?;
+ if look.name.chars().count() > 128 || !look.opacity.is_finite() || !(0.0..=1.0).contains(&look.opacity) {
+ return Err(DocError::Invalid("appearance requires a name up to 128 characters and opacity in 0..1".into()));
+ }
+ Ok(Some(look))
+}
+
+/// Parse a physical material cell, returning None for the default material.
+pub fn material_cell(value: &str) -> Result > {
+ let name = crate::MATERIALS
+ .iter()
+ .find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
+ .map(|(n, _)| *n)
+ .ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
+ Ok((name != "Default").then(|| name.to_string()))
+}
+
impl Document {
/// A column's current value in the design.
pub fn config_current(&self, c: &Column) -> Option {
match c {
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e),
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
+ Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
+ Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
}
}
- /// A column's display key: `param:` or `suppress:`.
+ /// A column's display key, prefixed by its kind to distinguish targets with equal names.
pub fn config_key(&self, c: &Column) -> String {
match c {
Column::Param(n) => format!("param:{n}"),
Column::Suppress(id) => format!("suppress:{}", self.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| id.to_string())),
+ Column::Material(body) => format!("material:{body}"),
+ Column::Appearance(body) => format!("appearance:{body}"),
}
}
/// Set the design to a row's values (all or nothing).
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
+ if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
+ return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
+ }
let mut next = self.clone();
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
+ if v.len() > 4096 {
+ return Err(DocError::Invalid("configuration value too long".into()));
+ }
match c {
Column::Param(n) => {
if next.config_current(c).as_deref() != Some(v.as_str()) {
@@ -76,6 +121,22 @@ impl Document {
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
f.suppressed = on;
}
+ Column::Material(body) => match material_cell(v)? {
+ Some(material) => {
+ next.materials.insert(body.clone(), material);
+ }
+ None => {
+ next.materials.remove(body);
+ }
+ },
+ Column::Appearance(body) => match appearance_cell(v)? {
+ Some(look) => {
+ next.appearances.bodies.insert(body.clone(), look);
+ }
+ None => {
+ next.appearances.bodies.remove(body);
+ }
+ },
}
}
next.configs.active = Some(row.to_string());
@@ -86,9 +147,14 @@ impl Document {
/// The active row's values all hold in the design.
pub fn configuration_matches(&self) -> bool {
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false };
+ if r.cells.len() != self.configs.columns.len() {
+ return false;
+ }
self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c {
Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()),
Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")),
+ Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
+ Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
})
}
}
@@ -114,4 +180,21 @@ mod tests {
assert!(d.apply_configuration("Bad").is_err());
assert_eq!(d, before, "all or nothing");
}
+
+ #[test]
+ fn invalid_saved_appearance_rolls_back_material_and_parameters() {
+ let mut d = Document::new("A");
+ d.set_param("width", "10 mm", None, None).unwrap();
+ d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())];
+ d.configs.rows.push(ConfigRow {
+ name: "Broken".into(),
+ cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()],
+ });
+ let before = d.clone();
+ assert!(d.apply_configuration("Broken").is_err());
+ assert_eq!(d, before);
+ assert!(appearance_cell(r#"{"name":"Invalid","color":[1,2,3],"opacity":1e999}"#).is_err());
+ assert!(appearance_cell(&"a".repeat(4097)).is_err());
+ assert!(appearance_cell("default").unwrap().is_none());
+ }
}
diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs
index 4b94a488..cf3cea21 100644
--- a/crates/doc/src/format.rs
+++ b/crates/doc/src/format.rs
@@ -20,13 +20,16 @@
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
//! explicit planes retaining their previous axes, including offset-plane expressions.
+//! - 7: configuration tables can include body material and appearance columns. Version 6
+//! rows need no transformation; the version stamp lets older readers request an update
+//! instead of encountering an unknown column variant.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
-pub const FORMAT: u32 = 6;
+pub const FORMAT: u32 = 7;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -39,7 +42,7 @@ pub fn version_of(s: &str) -> Option {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
-const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
+const JSON_STEPS: [JsonStep; 6] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(())];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
@@ -210,6 +213,65 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests {
use super::*;
+ #[test]
+ fn version_six_configuration_rows_upgrade_without_changing_values_or_features() {
+ use crate::config::{Column, ConfigRow};
+ let mut d = Document::new("legacy configurations");
+ d.set_param("width", "10 mm", None, None).unwrap();
+ let feature = d
+ .add_feature(
+ crate::FeatureKind::Box {
+ corner: ["0".into(), "0".into(), "0".into()],
+ length: "width".into(),
+ width: "20".into(),
+ height: "5".into(),
+ operation: crate::Operation::NewBody,
+ },
+ None,
+ )
+ .unwrap();
+ d.configs.columns = vec![Column::Param("width".into()), Column::Suppress(feature)];
+ d.configs.rows = vec![
+ ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] },
+ ConfigRow { name: "Alternate".into(), cells: vec!["width / 2 + 5 mm".into(), "yes".into()] },
+ ];
+ d.configs.active = Some("Default".into());
+ let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
+ legacy["format"] = Value::from("solvecraft/6");
+ let original_table = legacy["configs"].clone();
+ let upgraded = read(&legacy.to_string()).unwrap();
+ assert_eq!(upgraded, d, "the identity migration preserves the complete design");
+ assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
+ assert_eq!(upgraded.format, "solvecraft/7");
+ assert!(upgraded.configuration_matches());
+ legacy.as_object_mut().unwrap().remove("configs");
+ assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
+ legacy["configs"] = serde_json::json!({"columns":[],"rows":[]});
+ assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
+ }
+
+ #[test]
+ fn version_seven_styles_roundtrip_and_newer_versions_are_refused_before_columns() {
+ use crate::config::{Column, ConfigRow};
+ let mut d = Document::new("configured styles");
+ d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
+ d.configs.rows = vec![ConfigRow {
+ name: "Blue steel".into(),
+ cells: vec!["Steel".into(), r#"{"name":"Custom blue","color":[40,90,186],"opacity":0.4}"#.into()],
+ }];
+ d.apply_configuration("Blue steel").unwrap();
+ let mut value: Value = serde_json::from_str(&d.to_json()).unwrap();
+ assert_eq!(value["format"], "solvecraft/7");
+ let roundtrip = read(&value.to_string()).unwrap();
+ assert_eq!(roundtrip, d);
+ assert!(roundtrip.configuration_matches());
+ value["format"] = Value::from(format_string(FORMAT + 1));
+ value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
+ let error = read(&value.to_string()).unwrap_err().to_string();
+ assert!(error.contains("newer SolveCraft") && error.contains("update SolveCraft"), "{error}");
+ assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
+ }
+
#[test]
fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
let mut d = Document::new("input repair");
diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs
index 73e7c69a..e2b3d795 100644
--- a/crates/doc/src/params.rs
+++ b/crates/doc/src/params.rs
@@ -829,8 +829,10 @@ impl Document {
}
// Configuration cells refer to parameters too.
for r in &mut self.configs.rows {
- for cell in &mut r.cells {
- *cell = expr::rename_reference(cell, old, new);
+ for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) {
+ if matches!(column, crate::config::Column::Param(_)) {
+ *cell = expr::rename_reference(cell, old, new);
+ }
}
}
if let Some(c) = self.param_comments.remove(old) {
diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs
index 5378b1b8..677d72b1 100644
--- a/crates/doc/src/project.rs
+++ b/crates/doc/src/project.rs
@@ -20,6 +20,9 @@ const AXIS_HALF: f64 = 100.0;
/// Points in one fitted chain at most (longer chains are thinned).
const MAX_CHAIN: usize = 4000;
+#[path = "project_order.rs"]
+mod order;
+
/// A resolved link: its geometry in sketch coordinates and the (possibly moved) reference.
#[derive(Clone, Debug)]
pub struct Resolved {
@@ -133,7 +136,7 @@ fn resolve_uncached(
LinkKind::Intersect => section(plane, &mesh, Some(f)),
_ => {
let mut g = Vec::new();
- for e in mesh.face_edges(f) {
+ for e in order::face_edges(&mesh, f, plane)? {
if mesh.seams.get(e).copied().unwrap_or(false) {
continue;
}
diff --git a/crates/doc/src/project_order.rs b/crates/doc/src/project_order.rs
new file mode 100644
index 00000000..70fe4834
--- /dev/null
+++ b/crates/doc/src/project_order.rs
@@ -0,0 +1,290 @@
+//! Stable face projection order: outer boundary first, then holes by geometry.
+//! B-rep edge indices depend on face enumeration and must not choose sketch curve IDs.
+
+use std::collections::BTreeMap;
+
+use solvecraft_geom::{Mesh, Plane, Vec3};
+
+use crate::{DocError, Result};
+
+type Key = (i64, i64, i64);
+
+pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result> {
+ let bounds = mesh.bounds();
+ let origin = bounds.min;
+ let local_origin = plane.to_local(origin);
+ let tol = (bounds.diagonal() * 1e-8).max(1e-9);
+ if !tol.is_finite() {
+ return Err(DocError::Invalid("face projection has invalid bounds".into()));
+ }
+ let key = |p: Vec3| -> Key {
+ let p = p - origin;
+ ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64)
+ };
+ let mut edges: Vec = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
+ if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) {
+ return Err(DocError::Invalid("face projection has an invalid boundary edge".into()));
+ }
+ if edges.len() > solvecraft_sketch::MAX_LINK_ENTITIES {
+ return Err(DocError::Invalid("face projection exceeds the boundary edge limit".into()));
+ }
+ let geometry_key = |e: usize| {
+ let pts = mesh.edges.get(e).map(Vec::as_slice).unwrap_or_default();
+ let lo = pts.iter().copied().fold(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::min);
+ let hi = pts.iter().copied().fold(Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), Vec3::max);
+ (key(lo), key(hi))
+ };
+ edges.sort_by_key(|e| geometry_key(*e));
+ let mut at: BTreeMap> = BTreeMap::new();
+ for e in &edges {
+ if let Some(pts) = mesh.edges.get(*e) {
+ for point in [pts.first(), pts.last()].into_iter().flatten() {
+ at.entry(key(*point)).or_default().push(*e);
+ }
+ }
+ }
+ let mut used = vec![false; mesh.edges.len()];
+ let mut boundaries = Vec::new();
+ for start_edge in &edges {
+ if used.get(*start_edge).copied().unwrap_or(true) {
+ continue;
+ }
+ let Some(pts) = mesh.edges.get(*start_edge) else { continue };
+ let (Some(a), Some(b)) = (pts.first(), pts.last()) else { continue };
+ let start = if key(*a) <= key(*b) { *a } else { *b };
+ let mut current = start;
+ let mut edge = *start_edge;
+ let mut boundary = Vec::new();
+ let mut twice_area = 0.0;
+ for _ in 0..edges.len() {
+ let Some(pts) = mesh.edges.get(edge) else { break };
+ let (Some(a), Some(b)) = (pts.first(), pts.last()) else { break };
+ let forward = current.dist(*a) <= current.dist(*b);
+ for pair in pts.windows(2) {
+ let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
+ let (a, b) = (plane.to_local(*a) - local_origin, plane.to_local(*b) - local_origin);
+ twice_area += if forward { a.cross(b) } else { b.cross(a) };
+ }
+ current = if forward { *b } else { *a };
+ if let Some(slot) = used.get_mut(edge) {
+ *slot = true;
+ }
+ boundary.push(edge);
+ if current.dist(start) <= tol {
+ break;
+ }
+ // Neighbouring quantization cells cover points on either side of a rounding
+ // boundary. Connectivity is checked in world space, not in the projection.
+ let (x, y, z) = key(current);
+ let mut candidates = Vec::new();
+ for dx in -1..=1 {
+ for dy in -1..=1 {
+ for dz in -1..=1 {
+ let cell = (x.saturating_add(dx), y.saturating_add(dy), z.saturating_add(dz));
+ if let Some(list) = at.get(&cell) {
+ candidates.extend(list.iter().copied().filter(|e| {
+ !used.get(*e).copied().unwrap_or(true)
+ && mesh.edges.get(*e).is_some_and(|p| {
+ p.first().is_some_and(|a| a.dist(current) <= tol) || p.last().is_some_and(|b| b.dist(current) <= tol)
+ })
+ }));
+ }
+ }
+ }
+ }
+ candidates.sort_by_key(|e| geometry_key(*e));
+ candidates.dedup();
+ if candidates.len() > 1 {
+ return Err(DocError::Invalid("face projection has a branched boundary".into()));
+ }
+ let Some(next) = candidates.first() else { break };
+ edge = *next;
+ }
+ boundary.sort_by_key(|e| geometry_key(*e));
+ let closed = current.dist(start) <= tol;
+ if !closed || !twice_area.is_finite() {
+ return Err(DocError::Invalid("face projection has an open or invalid boundary".into()));
+ }
+ // Equal-sized holes use their geometric key, not round-off from summing the
+ // same area at different positions or walking their edges in reverse.
+ let area = (twice_area.abs() / (tol * bounds.diagonal().max(tol))).round() as i64;
+ let first = boundary.first().copied().map(geometry_key);
+ boundaries.push((area, first, boundary));
+ }
+ boundaries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
+ Ok(boundaries.into_iter().flat_map(|(_, _, edges)| edges).collect())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn rectangles() -> Mesh {
+ let mut mesh = Mesh::default();
+ for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] {
+ let pts = [Vec3::new(-x, -y, 0.0), Vec3::new(x, -y, 0.0), Vec3::new(x, y, 0.0), Vec3::new(-x, y, 0.0)];
+ mesh.positions.extend(pts);
+ for i in 0..4 {
+ mesh.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
+ mesh.edge_faces.push(vec![7]);
+ }
+ }
+ mesh
+ }
+
+ #[test]
+ fn boundaries_and_edge_identity_are_independent_of_mesh_enumeration() {
+ let baseline = rectangles();
+ let expected: Vec<_> =
+ face_edges(&baseline, 7, &Plane::XY).unwrap().into_iter().map(|e| super::super::fit_world(&Plane::XY, &baseline.edges[e])).collect();
+ for shift in 0..baseline.edges.len() {
+ for reverse in [false, true] {
+ let mut mesh = baseline.clone();
+ mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5) % baseline.edges.len()].clone()).collect();
+ mesh.edges.rotate_left(shift);
+ if reverse {
+ mesh.edges.reverse();
+ for pts in &mut mesh.edges {
+ pts.reverse();
+ }
+ }
+ let actual: Vec<_> = face_edges(&mesh, 7, &Plane::XY)
+ .unwrap()
+ .into_iter()
+ .map(|e| {
+ let mut pts = mesh.edges[e].clone();
+ // Lines' direction does not change their geometry.
+ if pts.first().unwrap().x > pts.last().unwrap().x || pts.first().unwrap().y > pts.last().unwrap().y {
+ pts.reverse();
+ }
+ super::super::fit_world(&Plane::XY, &pts)
+ })
+ .collect();
+ let normalized: Vec<_> = expected
+ .iter()
+ .map(|g| {
+ let solvecraft_sketch::LinkGeom::Line(a, b) = g[0] else { panic!("line") };
+ if a.x > b.x || a.y > b.y { vec![solvecraft_sketch::LinkGeom::Line(b, a)] } else { g.clone() }
+ })
+ .collect();
+ assert_eq!(actual, normalized);
+ let ordered = face_edges(&mesh, 7, &Plane::XY).unwrap();
+ for (loop_index, chunk) in ordered.chunks(4).enumerate() {
+ let want = [40.0, 38.0, 4.0][loop_index];
+ assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x.abs() <= want));
+ assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).any(|p| p.x.abs() == want));
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn rounded_boundary_arc_sweeps_survive_individual_edge_reversal() {
+ use solvecraft_geom::{Seg2, Vec2};
+ use solvecraft_sketch::LinkGeom;
+ let mut mesh = Mesh::default();
+ for (extent, radius) in [(10.0, 3.0), (8.0, 1.0)] {
+ for k in 0..4 {
+ let angle = k as f64 * std::f64::consts::FRAC_PI_2;
+ let rotate = |p: Vec2| (0..k).fold(p, |p, _| p.perp());
+ let centre = rotate(Vec2::new(extent - radius, extent - radius));
+ let arc = Seg2::Arc { center: centre, radius, start: angle, sweep: std::f64::consts::FRAC_PI_2 };
+ let line = Seg2::Line { a: arc.point_at(1.0), b: rotate(Vec2::new(-(extent - radius), extent)) };
+ for seg in [arc, line] {
+ let pts: Vec<_> = (0..=16).map(|i| Plane::XY.to_world(seg.point_at(i as f64 / 16.0))).collect();
+ mesh.positions.extend(pts.iter().copied());
+ mesh.edges.push(pts);
+ mesh.edge_faces.push(vec![2]);
+ }
+ }
+ }
+ let normalized = |mesh: &Mesh| -> Vec> {
+ face_edges(mesh, 2, &Plane::XY)
+ .unwrap()
+ .into_iter()
+ .map(|e| {
+ super::super::fit_world(&Plane::XY, &mesh.edges[e])
+ .into_iter()
+ .map(|g| match g {
+ LinkGeom::Line(a, b) if (a.x, a.y) > (b.x, b.y) => LinkGeom::Line(b, a),
+ g => g,
+ })
+ .collect()
+ })
+ .collect()
+ };
+ let expected = normalized(&mesh);
+ for shift in 0..mesh.edges.len() {
+ let mut permuted = mesh.clone();
+ permuted.edges = (0..mesh.edges.len()).map(|i| mesh.edges[(i * 5 + shift) % mesh.edges.len()].clone()).collect();
+ for (i, points) in permuted.edges.iter_mut().enumerate() {
+ if i % 2 == 0 {
+ points.reverse();
+ }
+ }
+ let actual = normalized(&permuted);
+ for (a, b) in actual.iter().zip(&expected) {
+ assert_eq!(a.len(), b.len());
+ for (a, b) in a.iter().zip(b) {
+ match (a, b) {
+ (LinkGeom::Line(a, b), LinkGeom::Line(c, d)) => assert!(a.dist(*c) < 1e-9 && b.dist(*d) < 1e-9),
+ (LinkGeom::Arc { c, a, b }, LinkGeom::Arc { c: cc, a: aa, b: bb }) => {
+ assert!(c.dist(*cc) < 1e-9 && a.dist(*aa) < 1e-9 && b.dist(*bb) < 1e-9);
+ }
+ _ => panic!("boundary changed kind"),
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn incomplete_and_branched_boundaries_are_reported_without_partial_geometry() {
+ let mut open = rectangles();
+ open.edges.pop();
+ open.edge_faces.pop();
+ assert!(face_edges(&open, 7, &Plane::XY).unwrap_err().to_string().contains("open"));
+ let mut branched = rectangles();
+ branched.edges.push(vec![Vec3::new(-40.0, -30.0, 0.0), Vec3::ZERO]);
+ branched.edge_faces.push(vec![7]);
+ assert!(face_edges(&branched, 7, &Plane::XY).is_err());
+ let mut invalid = rectangles();
+ invalid.edges[0].push(Vec3::new(f64::NAN, 0.0, 0.0));
+ assert!(face_edges(&invalid, 7, &Plane::XY).unwrap_err().to_string().contains("invalid"));
+ }
+
+ #[test]
+ fn equal_area_holes_use_geometric_order_after_translation() {
+ let mut baseline = rectangles();
+ baseline.edges.truncate(4);
+ baseline.edge_faces.truncate(4);
+ for x in [-12.0, 12.0] {
+ let pts = [Vec3::new(x - 2.0, -2.0, 0.0), Vec3::new(x + 2.0, -2.0, 0.0), Vec3::new(x + 2.0, 2.0, 0.0), Vec3::new(x - 2.0, 2.0, 0.0)];
+ for i in 0..4 {
+ baseline.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
+ baseline.edge_faces.push(vec![7]);
+ }
+ }
+ let shift = Vec3::new(12345.6789, -23456.7891, 987.0);
+ for p in &mut baseline.positions {
+ *p += shift;
+ }
+ for points in &mut baseline.edges {
+ for p in points {
+ *p += shift;
+ }
+ }
+ for offset in 0..baseline.edges.len() {
+ let mut mesh = baseline.clone();
+ mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5 + offset) % baseline.edges.len()].clone()).collect();
+ for (i, points) in mesh.edges.iter_mut().enumerate() {
+ if i % 2 == 0 {
+ points.reverse();
+ }
+ }
+ let order = face_edges(&mesh, 7, &Plane::XY).unwrap();
+ assert!(order[4..8].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x < shift.x));
+ assert!(order[8..12].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x > shift.x));
+ }
+ }
+}
diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs
index 1740ac04..3f906def 100644
--- a/crates/engine/src/cmd/browser.rs
+++ b/crates/engine/src/cmd/browser.rs
@@ -413,6 +413,14 @@ fn rename_body(s: &mut Session, p: &Value) -> Result {
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
f.body = name.to_string();
}
+ for column in &mut d.configs.columns {
+ match column {
+ solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => {
+ *body = name.to_string();
+ }
+ _ => {}
+ }
+ }
if let Some(o) = d.body_offsets.remove(&old) {
d.body_offsets.insert(name.to_string(), o);
}
diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs
index 62b8cc3a..193c896f 100644
--- a/crates/engine/src/cmd/component.rs
+++ b/crates/engine/src/cmd/component.rs
@@ -11,6 +11,9 @@ use super::CommandSpec;
use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{Result, Session};
+#[path = "component_properties.rs"]
+mod properties;
+
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.create", "New Component", new_component)
.at("SOLID", "ASSEMBLE")
@@ -21,6 +24,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
CommandSpec::new("component.list", "List Components", list).noundo(),
+ CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
+ .icon("measure").noundo()
+ .params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"),
+ CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
+ .icon("measure").noundo()
+ .params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"),
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
diff --git a/crates/engine/src/cmd/component_properties.rs b/crates/engine/src/cmd/component_properties.rs
new file mode 100644
index 00000000..bef6410b
--- /dev/null
+++ b/crates/engine/src/cmd/component_properties.rs
@@ -0,0 +1,142 @@
+//! Physical properties of an assembly placement, with original body material assignments.
+use serde_json::{Value, json};
+use solvecraft_doc::{Document, Mat};
+use solvecraft_geom::Vec3;
+
+use crate::params::bad;
+use crate::{Result, Session};
+
+fn descendants(p: &Value, cmd: &str) -> Result {
+ match p.get("descendants") {
+ None => Ok(true),
+ Some(Value::Bool(v)) => Ok(*v),
+ Some(_) => Err(bad(cmd, "`descendants` must be a boolean")),
+ }
+}
+
+fn placed_doc(s: &Session) -> Document {
+ let mut doc = (*s.doc).clone();
+ for occurrence in &mut doc.occurrences {
+ if let Some(transform) = s.pending_moves.get(&occurrence.id) {
+ occurrence.transform = *transform;
+ }
+ }
+ doc
+}
+
+/// A property total must cover the entire assembly. The display traversal intentionally
+/// truncates hostile designs, so use an explicit error instead of accepting its prefix.
+fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> {
+ let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
+ let mut stack = vec![(0, Vec::::new(), solvecraft_doc::IDENTITY)];
+ while let Some((component, path, transform)) = stack.pop() {
+ for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) {
+ if path.len() >= 64 || out.len() >= 100_000 {
+ return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
+ }
+ let mut child_path = path.clone();
+ child_path.push(occurrence.id);
+ let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform);
+ out.push((occurrence.component, child_path.clone(), world));
+ stack.push((occurrence.component, child_path, world));
+ }
+ }
+ Ok(out)
+}
+
+pub(super) fn component(s: &mut Session, p: &Value) -> Result {
+ let cmd = "component.properties";
+ let id = if p.get("component").is_some() { super::component(s, p, "component", cmd)? } else { s.active_component };
+ let doc = placed_doc(s);
+ let path = doc.first_path(id);
+ let name = if id == 0 {
+ doc.name.clone()
+ } else {
+ doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
+ };
+ let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
+ out["component"] = json!(id);
+ out["name"] = json!(name);
+ Ok(out)
+}
+
+pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result {
+ let cmd = "occurrence.properties";
+ let id = super::occurrence(s, p, cmd)?;
+ let doc = placed_doc(s);
+ let paths: Vec> = placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect();
+ let path = if let Some(value) = p.get("path") {
+ let path = value
+ .as_array()
+ .filter(|a| !a.is_empty() && a.len() <= 64)
+ .and_then(|a| a.iter().map(Value::as_u64).collect::>>())
+ .ok_or_else(|| bad(cmd, "`path` must list 1…64 occurrence ids"))?;
+ if !paths.contains(&path) {
+ return Err(bad(cmd, "`path` does not identify a placement of this occurrence"));
+ }
+ path
+ } else {
+ match paths.as_slice() {
+ [path] => path.clone(),
+ [] => return Err(bad(cmd, "occurrence has no placement")),
+ _ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
+ }
+ };
+ let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
+ out["occurrence"] = json!(id);
+ out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
+ Ok(out)
+}
+
+fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, cmd: &str) -> Result {
+ let state = s.model.state();
+ let mut bodies = Vec::new();
+ let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
+ let mut moment = Vec3::ZERO;
+ let (mut min, mut max) =
+ (Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY));
+ let (mut faces, mut edges, mut vertices) = (0, 0, 0);
+ let mut placement_count = 0;
+ for (component, path, transform) in
+ placements(doc, cmd)?.into_iter().filter(|(_, path, _)| if descendants { path.starts_with(selected) } else { path == selected })
+ {
+ placement_count += 1;
+ for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) {
+ let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
+ let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?;
+ let measured = solvecraft_kernel::measure(&placed)?;
+ let density = doc.density(&body.name);
+ let body_mass = measured.volume * density / 1000.0;
+ volume += measured.volume;
+ area += measured.area;
+ mass += body_mass;
+ moment += measured.centroid * body_mass;
+ min = Vec3::new(min.x.min(measured.bbox.min.x), min.y.min(measured.bbox.min.y), min.z.min(measured.bbox.min.z));
+ max = Vec3::new(max.x.max(measured.bbox.max.x), max.y.max(measured.bbox.max.y), max.z.max(measured.bbox.max.z));
+ faces += measured.merged.faces;
+ edges += measured.merged.edges;
+ vertices += measured.merged.vertices;
+ bodies.push(json!({"name": body.name, "component": component, "path": path,
+ "material": doc.materials.get(&body.name).map(String::as_str).unwrap_or("Default"),
+ "density_g_cm3": density, "mass_g": body_mass, "volume_mm3": measured.volume,
+ "area_mm2": measured.area, "center_of_mass": measured.centroid,
+ "bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
+ }
+ }
+ if !(volume.is_finite() && area.is_finite() && mass.is_finite() && moment.is_finite()) {
+ return Err(bad(cmd, "physical properties exceed supported numeric range"));
+ }
+ let centroid = if mass > 0.0 { Some(moment / mass) } else { None };
+ if centroid.is_some_and(|point| !point.is_finite()) {
+ return Err(bad(cmd, "center of mass exceeds supported numeric range"));
+ }
+ let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": min, "max": max}) };
+ Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
+ "placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
+ "total": {"volume_mm3": volume, "area_mm2": area, "mass_g": mass,
+ "center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}}))
+}
+
+#[cfg(test)]
+#[path = "component_properties_tests.rs"]
+mod tests;
diff --git a/crates/engine/src/cmd/component_properties_tests.rs b/crates/engine/src/cmd/component_properties_tests.rs
new file mode 100644
index 00000000..a9425c9d
--- /dev/null
+++ b/crates/engine/src/cmd/component_properties_tests.rs
@@ -0,0 +1,129 @@
+use crate::Session;
+use serde_json::{Value, json};
+
+fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
+ s.execute(cmd, ¶ms).unwrap()
+}
+fn close(actual: &Value, expected: f64) {
+ assert!((actual.as_f64().unwrap() - expected).abs() < 1e-6, "{actual} != {expected}");
+}
+fn box_in(s: &mut Session, name: &str) -> (u64, u64, String) {
+ let c = run(s, "component.create", json!({"name": name}));
+ run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
+ let body = s.model.state().bodies.last().unwrap().name.clone();
+ (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap(), body)
+}
+
+#[test]
+fn assembly_counts_instances_and_resolves_original_materials() {
+ let mut s = Session::default();
+ let (component, occurrence, body) = box_in(&mut s, "Block");
+ run(&mut s, "material.assign", json!({"bodies": [body], "material": "Steel"}));
+ let copy = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}));
+ let rho = s.doc.density(&body);
+ let root = run(&mut s, "component.properties", json!({"component": "root"}));
+ assert_eq!(root["body_count"], 2);
+ close(&root["total"]["mass_g"], 2.0 * rho);
+ close(&root["total"]["volume_mm3"], 2000.0);
+ close(&root["total"]["center_of_mass"][0], 15.0);
+ let first = run(&mut s, "component.properties", json!({"component": component}));
+ assert_eq!(first["body_count"], 1);
+ close(&first["total"]["center_of_mass"][0], 5.0);
+ let second = run(&mut s, "occurrence.properties", json!({"occurrence": copy["occurrence"]}));
+ close(&second["total"]["center_of_mass"][0], 25.0);
+ assert_eq!(second["bodies"][0]["material"], "Steel");
+ assert_eq!(first["path"], json!([occurrence]));
+}
+
+#[test]
+fn mass_centroid_is_density_weighted_and_pending_moves_are_read_only() {
+ let mut s = Session::default();
+ let (a, oa, steel) = box_in(&mut s, "Steel block");
+ run(&mut s, "material.assign", json!({"bodies": [steel], "material": "Steel"}));
+ run(&mut s, "component.activate", json!({"component": "root"}));
+ let (_, ob, plastic) = box_in(&mut s, "Plastic block");
+ run(&mut s, "material.assign", json!({"bodies": [plastic], "material": "ABS Plastic"}));
+ run(&mut s, "occurrence.move", json!({"occurrence": ob, "translate": [30, 0, 0], "capture": false}));
+ let doc = s.doc.clone();
+ let moves = s.pending_moves.clone();
+ let r = run(&mut s, "component.properties", json!({"component": 0}));
+ let ra = s.doc.density(&steel);
+ let rb = s.doc.density(&plastic);
+ close(&r["total"]["center_of_mass"][0], (5.0 * ra + 35.0 * rb) / (ra + rb));
+ close(&r["total"]["bbox"]["max"][0], 40.0);
+ assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
+ assert_eq!(s.pending_moves, moves);
+ run(&mut s, "occurrence.move", json!({"occurrence": oa, "translate": [10, 0, 0], "axis": [0, 0, 1], "angle": "90 deg"}));
+ let r = run(&mut s, "component.properties", json!({"component": a}));
+ close(&r["total"]["center_of_mass"][0], 5.0);
+ close(&r["total"]["center_of_mass"][1], 5.0);
+}
+
+#[test]
+fn nested_instances_require_a_path_and_can_exclude_descendants() {
+ let mut s = Session::default();
+ let parent = run(&mut s, "component.create", json!({"name": "Assembly"}));
+ let (_, child_occurrence, _) = box_in(&mut s, "Child");
+ let copy = run(&mut s, "occurrence.copy", json!({"component": parent["component"], "translate": [100, 0, 0]}));
+ assert!(s.execute("occurrence.properties", &json!({"occurrence": child_occurrence})).is_err());
+ let r = run(&mut s, "occurrence.properties", json!({"occurrence": child_occurrence, "path": [copy["occurrence"], child_occurrence]}));
+ close(&r["total"]["center_of_mass"][0], 105.0);
+ let r = run(&mut s, "component.properties", json!({"component": parent["component"]}));
+ assert_eq!(r["body_count"], 1);
+ let r = run(&mut s, "component.properties", json!({"component": parent["component"], "descendants": false}));
+ assert_eq!(r["body_count"], 0);
+ assert!(r["total"]["center_of_mass"].is_null());
+ assert!(r["total"]["bbox"].is_null());
+}
+
+#[test]
+fn body_reassignment_offsets_preserve_world_properties() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
+ let body = s.model.state().bodies.first().unwrap().name.clone();
+ let c = run(&mut s, "component.create", json!({"name": "Moved"}));
+ run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "translate": [100, 0, 0]}));
+ run(&mut s, "component.move_bodies", json!({"bodies": [body], "component": c["component"]}));
+ let r = run(&mut s, "component.properties", json!({"component": c["component"]}));
+ close(&r["total"]["center_of_mass"][0], 5.0);
+}
+
+#[test]
+fn properties_reject_hostile_identifiers_and_options() {
+ let mut s = Session::default();
+ let (_, occurrence, _) = box_in(&mut s, "Block");
+ for p in [json!({"component": []}), json!({"component": "missing"}), json!({"descendants": 1})] {
+ assert!(s.execute("component.properties", &p).is_err());
+ }
+ for path in [json!([]), json!(["bad"]), json!([0]), json!(vec![occurrence; 65])] {
+ assert!(s.execute("occurrence.properties", &json!({"occurrence": occurrence, "path": path})).is_err());
+ }
+}
+
+#[test]
+fn compact_exponentially_instanced_assembly_refuses_partial_totals() {
+ let mut s = Session::default();
+ let mut last_occurrence = 0;
+ // Only 17 component definitions and 34 occurrences, but over 100000 placements.
+ for _ in 0..17 {
+ let parent = s.active_component;
+ let component = s.doc_mut().add_component(None, parent).unwrap();
+ last_occurrence = s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap();
+ s.active_component = component;
+ }
+ for command in ["component.properties", "occurrence.properties"] {
+ let error = s.execute(command, &json!({"component": 0, "occurrence": last_occurrence})).unwrap_err();
+ assert!(error.to_string().contains("traversal limits"), "{error}");
+ }
+}
+
+#[test]
+fn excessively_nested_assembly_refuses_partial_totals() {
+ let mut s = Session::default();
+ for _ in 0..65 {
+ let parent = s.active_component;
+ s.active_component = s.doc_mut().add_component(None, parent).unwrap();
+ }
+ let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
+ assert!(error.to_string().contains("traversal limits"), "{error}");
+}
diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs
index 74f2c8dc..334e2bf8 100644
--- a/crates/engine/src/cmd/config.rs
+++ b/crates/engine/src/cmd/config.rs
@@ -10,21 +10,33 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
- "params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
+ "params?: [parameter names]; features?: [feature names]; materials?: [body names]; appearances?: [body names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
),
CommandSpec::new("config.table", "Display Configuration Table", show)
.at("SOLID", "CONFIGURE")
.icon("params")
.noundo()
.params("→ columns (param: or suppress:), rows, the active row and whether the design still matches it"),
- CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? (name); delete?: bool"),
+ CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? | material? | appearance? (target name); delete?: bool"),
CommandSpec::new("config.row", "Configuration Row", row).params(
- "name; from? (row to copy; default the current values); values?: {param name or feature name: expression or true/false}; rename?; delete?: bool",
+ "name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
),
CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"),
];
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result {
+ if ["param", "feature", "material", "appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 {
+ return Err(bad(cmd, "give exactly one of `param`, `feature`, `material`, or `appearance`"));
+ }
+ for (key, appearance) in [("material", false), ("appearance", true)] {
+ if let Some(body) = str_(p, key) {
+ let c = if appearance { Column::Appearance(body.to_string()) } else { Column::Material(body.to_string()) };
+ if !s.doc.configs.columns.contains(&c) && s.model.state().body(body).is_none() {
+ return Err(bad(cmd, format!("no body `{body}`")));
+ }
+ return Ok(c);
+ }
+ }
if let Some(n) = str_(p, "param") {
if !s.doc.all_param_exprs().iter().any(|(m, _, _)| m == n) {
return Err(bad(cmd, format!("no parameter `{n}`")));
@@ -63,6 +75,13 @@ fn configure(s: &mut Session, p: &Value) -> Result {
let c = column_of(s, &json!({ "feature": n }), cmd)?;
add_column(s, c, cmd)?;
}
+ for key in ["materials", "appearances"] {
+ for n in string_list(p, key) {
+ let field = if key == "materials" { "material" } else { "appearance" };
+ let c = column_of(s, &json!({ field: n }), cmd)?;
+ add_column(s, c, cmd)?;
+ }
+ }
if s.doc.configs.rows.is_empty() {
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
let t = &mut s.doc_mut().configs;
@@ -122,32 +141,54 @@ fn row(s: &mut Session, p: &Value) -> Result {
if let Some(vals) = p.get("values") {
let vals = vals.as_object().ok_or_else(|| bad(cmd, "`values` must map columns to values"))?;
for (k, v) in vals {
- let text = match v {
+ let mut text = match v {
Value::String(t) => t.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
+ Value::Object(_) if k.starts_with("appearance:") => serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?,
_ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))),
};
if text.len() > 4096 {
return Err(bad(cmd, "value too long"));
}
- let key = k.strip_prefix("param:").or_else(|| k.strip_prefix("suppress:")).unwrap_or(k);
let col = s
.doc
.configs
.columns
.iter()
- .position(|c| match c {
- Column::Param(n) => n == key,
- Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == key),
+ .position(|c| {
+ s.doc.config_key(c) == *k
+ || match c {
+ Column::Param(n) => n == k,
+ Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == *k),
+ Column::Material(_) | Column::Appearance(_) => false,
+ }
})
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
+ match s.doc.configs.columns.get(col) {
+ Some(Column::Material(_)) => {
+ text = solvecraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
+ }
+ Some(Column::Appearance(_)) => {
+ text = match solvecraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
+ Some(look) => serde_json::to_string(&look).map_err(|e| bad(cmd, e.to_string()))?,
+ None => String::new(),
+ };
+ }
+ _ => {}
+ }
if let Some(cell) = cells.get_mut(col) {
*cell = text;
}
}
}
let rename = str_(p, "rename").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).map(str::to_string);
+ if p.get("rename").is_some() && (rename.is_none() || idx.is_none()) {
+ return Err(bad(cmd, "rename requires an existing row and a nonempty name up to 128 bytes"));
+ }
+ if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) {
+ return Err(bad(cmd, "configuration name already exists"));
+ }
let t = &mut s.doc_mut().configs;
match idx {
Some(i) => {
@@ -236,4 +277,136 @@ mod tests {
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1));
}
+
+ #[test]
+ fn configuration_materials_and_looks_are_undoable_and_persistent() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
+ let body = s.model.state().bodies.first().unwrap().name.clone();
+ run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
+ let material = format!("material:{body}");
+ let appearance = format!("appearance:{body}");
+ run(&mut s, "config.row", json!({"name": "Metal", "values": {material.clone(): "steel", appearance.clone(): "ABS - Red"}}));
+ run(
+ &mut s,
+ "config.row",
+ json!({"name": "Custom", "from": "Metal", "values": {
+ material.clone(): "Aluminum", appearance.clone(): {"name": "Ocean", "color": [1, 2, 3], "opacity": 0.4}
+ }}),
+ );
+ run(&mut s, "config.activate", json!({"row": "Metal"}));
+ assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
+ assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().color, [196, 40, 40]);
+ let mass = run(&mut s, "inspect.measure", json!({}));
+ assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 7.85).abs() < 1e-6);
+ assert_eq!(run(&mut s, "config.table", json!({}))["matches"], true);
+ run(&mut s, "config.activate", json!({"row": "Custom"}));
+ let mass = run(&mut s, "inspect.measure", json!({}));
+ assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 2.7).abs() < 1e-6);
+ assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().opacity, 0.4);
+ let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
+ assert_eq!(reopened, *s.doc);
+ assert!(reopened.configuration_matches());
+ run(&mut s, "edit.undo", json!({}));
+ assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
+ run(&mut s, "config.activate", json!({"row": "Default"}));
+ assert!(!s.doc.materials.contains_key(&body));
+ assert!(!s.doc.appearances.bodies.contains_key(&body));
+ run(&mut s, "config.activate", json!({"row": "Metal"}));
+ run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": "ABS - Blue"}));
+ assert!(!s.doc.configuration_matches());
+ run(&mut s, "config.column", json!({"appearance": body, "delete": true}));
+ assert_eq!(s.doc.configs.columns.len(), 1);
+ assert!(s.doc.configs.rows.iter().all(|r| r.cells.len() == 1));
+ }
+
+ #[test]
+ fn configuration_styles_reject_hostile_values_without_changes() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
+ let body = s.model.state().bodies.first().unwrap().name.clone();
+ run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
+ let material = format!("material:{body}");
+ let appearance = format!("appearance:{body}");
+ for params in [
+ json!({"name": "Bad", "values": {material: "Unobtainium"}}),
+ json!({"name": "Bad", "values": {appearance.clone(): "#oops"}}),
+ json!({"name": "Bad", "values": {appearance.clone(): {"name": "Invalid", "color": [1, 2, 300], "opacity": 1}}}),
+ json!({"name": "Bad", "values": {appearance: {"name": "Invalid", "color": [1, 2, 3], "opacity": -1}}}),
+ json!({"name": "Default", "rename": ""}),
+ ] {
+ let before = s.doc.clone();
+ assert!(s.execute("config.row", ¶ms).is_err(), "{params}");
+ assert_eq!(s.doc, before);
+ }
+ assert!(s.execute("config.column", &json!({"appearance": "missing"})).is_err());
+ assert!(s.execute("config.column", &json!({"appearance": body, "material": body})).is_err());
+ run(&mut s, "config.row", json!({"name": "Other"}));
+ assert!(s.execute("config.row", &json!({"name": "Other", "rename": "Default"})).is_err());
+ // A malformed persisted row must fail atomically rather than silently apply its prefix.
+ s.doc_mut().configs.rows.first_mut().unwrap().cells.pop();
+ let before = s.doc.clone();
+ assert!(s.execute("config.activate", &json!({"row": "Default"})).is_err());
+ assert_eq!(s.doc, before);
+ }
+
+ #[test]
+ fn configuration_captures_unicode_custom_appearance_names() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
+ let body = s.model.state().bodies.first().unwrap().name.clone();
+ let name = "é".repeat(128);
+ run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": name, "color": [40, 90, 186]}));
+ run(&mut s, "config.configure", json!({"appearances": [body]}));
+ run(&mut s, "appearance.assign", json!({"bodies": [body]}));
+ run(&mut s, "config.activate", json!({"row": "Default"}));
+ assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().name, name);
+ assert!(s.doc.configuration_matches());
+ let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
+ assert!(reopened.configuration_matches());
+ }
+
+ #[test]
+ fn configuration_styles_survive_parameter_and_body_renames() {
+ let mut s = Session::default();
+ run(&mut s, "parameters.change", json!({"name": "Steel", "expression": "10 mm"}));
+ run(&mut s, "parameters.change", json!({"name": "double", "expression": "Steel * 2"}));
+ run(&mut s, "solid.box", json!({"length": "double", "width": 10, "height": 10, "body_name": "Block"}));
+ run(&mut s, "config.configure", json!({"params": ["Steel", "double"], "materials": ["Block"], "appearances": ["Block"]}));
+ run(
+ &mut s,
+ "config.row",
+ json!({"name": "Styled", "values": {
+ "param:Steel": "20 mm", "param:double": "Steel * 3", "material:Block": "Steel", "appearance:Block": "Steel - Satin"
+ }}),
+ );
+ run(&mut s, "config.activate", json!({"row": "Styled"}));
+ let styles: Vec> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
+ run(&mut s, "parameters.rename", json!({"name": "Steel", "new_name": "gauge"}));
+ let after: Vec> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
+ assert_eq!(after, styles, "renaming an expression reference must preserve style cells verbatim");
+ let table = run(&mut s, "config.table", json!({}));
+ assert_eq!(table["columns"][0], "param:gauge");
+ assert_eq!(table["rows"][1]["values"]["param:double"], "gauge * 3");
+ assert!(s.doc.configuration_matches());
+ run(&mut s, "body.rename", json!({"body": "Block", "name": "Housing"}));
+ let table = run(&mut s, "config.table", json!({}));
+ assert_eq!(table["columns"][2], "material:Housing");
+ assert_eq!(table["columns"][3], "appearance:Housing");
+ assert!(s.doc.configuration_matches());
+ run(&mut s, "edit.undo", json!({}));
+ assert_eq!(run(&mut s, "config.table", json!({}))["columns"][2], "material:Block");
+ run(&mut s, "edit.redo", json!({}));
+ let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
+ run(&mut reopened, "config.activate", json!({"row": "Default"}));
+ assert!(!reopened.doc.materials.contains_key("Housing"));
+ assert!(!reopened.doc.appearances.bodies.contains_key("Housing"));
+ run(&mut reopened, "config.activate", json!({"row": "Styled"}));
+ assert_eq!(reopened.doc.materials.get("Housing").map(String::as_str), Some("Steel"));
+ assert_eq!(reopened.doc.appearances.bodies.get("Housing").unwrap().name, "Steel - Satin");
+ assert!(!reopened.doc.materials.contains_key("Block"));
+ assert!(!reopened.doc.appearances.bodies.contains_key("Block"));
+ assert!((volume(&mut reopened) - 6000.0).abs() < 1e-6);
+ assert!(reopened.doc.configuration_matches());
+ }
}
diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs
new file mode 100644
index 00000000..45fc941c
--- /dev/null
+++ b/crates/engine/src/cmd/contact_sweep.rs
@@ -0,0 +1,251 @@
+//! Bounded, chronological checks of a joint trajectory. This samples tessellated geometry;
+//! it is deliberately not advertised as exact continuous collision detection.
+use std::collections::{BTreeSet, HashMap};
+use std::sync::Arc;
+
+use solvecraft_doc::joints;
+use solvecraft_doc::{IDENTITY, Mat, apply_point, mat_mul};
+use solvecraft_geom::{Aabb3, Mesh, Vec3};
+
+use crate::params::bad;
+use crate::{Result, Session};
+
+const MAX_SAMPLES: usize = 4096;
+const MAX_PLACEMENTS: usize = 4096;
+const MAX_BODIES: usize = 256;
+const MAX_TRIANGLES: usize = 50_000;
+const MAX_WORK: usize = 50_000_000;
+
+fn limit(message: &str) -> crate::EngineError {
+ bad("contact.sweep", message)
+}
+
+struct CachedBody {
+ path: Vec,
+ offset: Mat,
+ mesh: Arc,
+ size: f64,
+}
+
+pub(super) struct ContactGeometry {
+ bodies: Vec,
+ pairs: Vec<(usize, usize, String, String)>,
+ step: f64,
+}
+
+impl ContactGeometry {
+ pub(super) fn new(s: &Session) -> Result {
+ let assembly = &s.doc.assembly;
+ let sets: Vec> = if assembly.contact_all {
+ vec![s.doc.occurrences.iter().map(|o| o.id).collect()]
+ } else {
+ assembly.contact_sets.iter().filter(|set| !set.suppressed).map(|set| set.occurrences.clone()).collect()
+ };
+ let mut out = Self { bodies: Vec::new(), pairs: Vec::new(), step: f64::INFINITY };
+ if sets.is_empty() {
+ return Ok(out);
+ }
+ let state = s.model.state();
+ let mut meshes: HashMap> = HashMap::new();
+ let mut stack = vec![(0, Vec::::new())];
+ let mut count = 0;
+ while let Some((component, path)) = stack.pop() {
+ count += 1;
+ if count > MAX_PLACEMENTS || path.len() > 64 {
+ return Err(limit("assembly exceeds contact sweep placement budget"));
+ }
+ for occurrence in s.doc.occurrences.iter().filter(|o| o.parent == component) {
+ let mut child = path.clone();
+ child.push(occurrence.id);
+ stack.push((occurrence.component, child));
+ if stack.len() > MAX_PLACEMENTS {
+ return Err(limit("assembly exceeds contact sweep placement budget"));
+ }
+ }
+ if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) {
+ continue;
+ }
+ for body in state.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == component) {
+ if body.body.is_mesh() {
+ return Err(limit("contact sweep does not support mesh bodies; disable their contact set to move them"));
+ }
+ if out.bodies.len() >= MAX_BODIES {
+ return Err(limit("too many bodies for a contact sweep"));
+ }
+ let mesh = if let Some(mesh) = meshes.get(&body.name) {
+ mesh.clone()
+ } else {
+ let mesh = body.body.tessellate((body.body.size() * 1e-3).max(1e-3))?;
+ if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len().saturating_add(mesh.triangles.len().saturating_mul(4)) > 200_000 {
+ return Err(limit("body exceeds contact sweep tessellation budget"));
+ }
+ let extent = mesh.bounds().size();
+ // Whole-body widths along bounded distinct facet normals catch tilted
+ // plates without interpreting skinny mesh triangles as thin material.
+ // Local thin walls of a wider body remain a sampling limitation.
+ let mut thickness = extent.x.min(extent.y).min(extent.z);
+ let mut directions: Vec = Vec::new();
+ for triangle in &mesh.triangles {
+ let Some([a, b, c]) = mesh.tri(triangle) else {
+ return Err(limit("invalid contact tessellation"));
+ };
+ let Some(direction) = (b - a).cross(c - a).normalized() else { continue };
+ if directions.len() < 32 && !directions.iter().any(|d| d.dot(direction).abs() > 1.0 - 1e-6) {
+ directions.push(direction);
+ }
+ }
+ for direction in directions {
+ let (mut low, mut high) = (f64::INFINITY, f64::NEG_INFINITY);
+ for point in &mesh.positions {
+ let projected = point.dot(direction);
+ low = low.min(projected);
+ high = high.max(projected);
+ }
+ thickness = thickness.min(high - low);
+ }
+ if !thickness.is_finite() || thickness <= 1e-9 {
+ return Err(limit("body is too thin for a bounded contact sweep"));
+ }
+ out.step = out.step.min(thickness * 0.25);
+ let mesh = Arc::new(mesh);
+ meshes.insert(body.name.clone(), mesh.clone());
+ mesh
+ };
+ out.bodies.push(CachedBody {
+ path: path.clone(),
+ offset: s.doc.body_offsets.get(&body.name).copied().unwrap_or(IDENTITY),
+ mesh,
+ size: body.body.size(),
+ });
+ }
+ }
+ let mut pairs = BTreeSet::new();
+ for set in &sets {
+ let tagged: Vec<(usize, u64)> =
+ out.bodies.iter().enumerate().filter_map(|(i, b)| b.path.iter().rev().find(|id| set.contains(id)).map(|id| (i, *id))).collect();
+ for (i, (a, oa)) in tagged.iter().enumerate() {
+ for (b, ob) in tagged.iter().skip(i + 1) {
+ if oa != ob {
+ pairs.insert((*a, *b, *oa, *ob));
+ }
+ }
+ }
+ }
+ let name = |id| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
+ out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, name(oa), name(ob))).collect();
+ Ok(out)
+ }
+
+ pub(super) fn has_pairs(&self) -> bool {
+ !self.pairs.is_empty()
+ }
+
+ fn frame(body: &CachedBody, s: &Session) -> Result {
+ let mut frame = IDENTITY;
+ for id in &body.path {
+ let occurrence = s.doc.occurrences.iter().find(|o| o.id == *id).ok_or_else(|| limit("contact occurrence disappeared"))?;
+ frame = mat_mul(&frame, &occurrence.transform);
+ }
+ Ok(mat_mul(&frame, &body.offset))
+ }
+
+ fn bounds(&self, s: &Session) -> Result {
+ let mut bounds = Aabb3::EMPTY;
+ for body in &self.bodies {
+ let frame = Self::frame(body, s)?;
+ for point in &body.mesh.positions {
+ bounds.add(apply_point(&frame, *point));
+ }
+ }
+ Ok(bounds)
+ }
+
+ pub(super) fn samples(&self, start: &Session, end: &Session) -> Result {
+ let a = joints::joint_values(&start.doc.assembly);
+ let b = joints::joint_values(&end.doc.assembly);
+ let bounds = self.bounds(start)?.union(&self.bounds(end)?);
+ let state = start.model.state();
+ let (mut linear, mut angular, mut radius) = (0.0, 0.0, 0.0_f64);
+ for joint in start.doc.assembly.joints.iter().filter(|j| !j.suppressed) {
+ for (index, _) in joint.kind.dofs().iter().enumerate() {
+ let delta = (a.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0)
+ - b.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0))
+ .abs();
+ if joint.kind.is_angle(index) {
+ angular += delta;
+ if delta == 0.0 {
+ continue;
+ }
+ for session in [start, end] {
+ for origin in [&joint.a, &joint.b] {
+ let frame = session.doc.origin_world(&state, origin)?;
+ let pivot = apply_point(&frame, Vec3::ZERO);
+ let far = Vec3::new(
+ (bounds.min.x - pivot.x).abs().max((bounds.max.x - pivot.x).abs()),
+ (bounds.min.y - pivot.y).abs().max((bounds.max.y - pivot.y).abs()),
+ (bounds.min.z - pivot.z).abs().max((bounds.max.z - pivot.z).abs()),
+ );
+ radius = radius.max(far.len());
+ }
+ }
+ } else {
+ linear += delta;
+ }
+ }
+ }
+ let required = ((linear + angular * (radius + linear)) / self.step).max(angular / 1_f64.to_radians()).ceil().max(1.0);
+ if !required.is_finite() || required > MAX_SAMPLES as f64 {
+ return Err(limit("motion exceeds contact sweep sampling budget; drive the joint in smaller increments"));
+ }
+ let vertices: usize = self.bodies.iter().map(|body| body.mesh.positions.len()).sum();
+ if (required as usize).saturating_mul(vertices.saturating_add(self.pairs.len())) > MAX_WORK {
+ return Err(limit("motion exceeds contact sweep transform budget; simplify the contact set or use smaller increments"));
+ }
+ Ok(required as usize)
+ }
+
+ pub(super) fn first_hit(&self, s: &Session, work: &mut usize) -> Result> {
+ let meshes: Vec = self
+ .bodies
+ .iter()
+ .map(|body| {
+ let frame = Self::frame(body, s)?;
+ let positions = body.mesh.positions.iter().map(|p| apply_point(&frame, *p)).collect();
+ Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() })
+ })
+ .collect::>()?;
+ for (a, b, aname, bname) in &self.pairs {
+ let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else {
+ return Err(limit("invalid contact geometry pair"));
+ };
+ let (aabb, bbb) = (ma.bounds(), mb.bounds());
+ if aabb.max.x < bbb.min.x
+ || bbb.max.x < aabb.min.x
+ || aabb.max.y < bbb.min.y
+ || bbb.max.y < aabb.min.y
+ || aabb.max.z < bbb.min.z
+ || bbb.max.z < aabb.min.z
+ {
+ continue;
+ }
+ let cost = ma
+ .positions
+ .len()
+ .saturating_add(ma.triangles.len().saturating_mul(4))
+ .saturating_mul(mb.triangles.len())
+ .saturating_add(mb.positions.len().saturating_add(mb.triangles.len().saturating_mul(4)).saturating_mul(ma.triangles.len()));
+ *work = work.saturating_add(cost);
+ if *work > MAX_WORK {
+ return Err(limit("motion exceeds contact sweep collision budget; simplify the contact set or use smaller increments"));
+ }
+ if let Some(depth) = super::mesh_overlap(ma, mb, 1e-4 * ba.size.max(bb.size).max(1.0)) {
+ return Ok(Some((aname.clone(), bname.clone(), depth)));
+ }
+ }
+ Ok(None)
+ }
+}
+
+#[cfg(test)]
+#[path = "contact_sweep_tests.rs"]
+mod tests;
diff --git a/crates/engine/src/cmd/contact_sweep_tests.rs b/crates/engine/src/cmd/contact_sweep_tests.rs
new file mode 100644
index 00000000..6e7b6baf
--- /dev/null
+++ b/crates/engine/src/cmd/contact_sweep_tests.rs
@@ -0,0 +1,170 @@
+use crate::Session;
+use serde_json::{Value, json};
+use solvecraft_geom::Vec3;
+
+fn run(s: &mut Session, command: &str, p: Value) -> Value {
+ s.execute(command, &p).unwrap()
+}
+fn occurrence(s: &Session, name: &str) -> u64 {
+ s.doc.occurrence_of(s.doc.find_component(name).unwrap()).unwrap().id
+}
+fn slider() -> Session {
+ let mut s = Session::default();
+ for (name, x) in [("A", 0), ("B", 20)] {
+ run(&mut s, "component.activate", json!({"component": 0}));
+ run(&mut s, "component.create", json!({"name": name}));
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
+ }
+ let a = occurrence(&s, "A");
+ run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
+ run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
+ run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
+ run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
+ s
+}
+fn rail_value(s: &Session) -> f64 {
+ s.doc.assembly.joints.iter().find(|j| j.name == "Rail").unwrap().values[0]
+}
+
+#[test]
+fn slider_cannot_tunnel_to_a_collision_free_endpoint() {
+ let mut s = slider();
+ let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -30}));
+ assert!(result["stopped_by_contact"].is_object(), "{result}");
+ assert!(rail_value(&s).abs() < 2e-3, "{}", rail_value(&s));
+ run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
+ assert!((rail_value(&s) - 10.0).abs() < 1e-6, "can move away from contact");
+ run(&mut s, "contact.disable_all", json!({}));
+ run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -100000}));
+ assert!((rail_value(&s) + 100000.0).abs() < 1e-6, "disabled contact skips sweep budget");
+}
+
+#[test]
+fn revolute_cannot_cross_a_blocker_between_clear_endpoints() {
+ let mut s = Session::default();
+ for (name, corner) in [("Arm", [20, -2, -2]), ("Blocker", [-2, 20, -2])] {
+ run(&mut s, "component.activate", json!({"component": 0}));
+ run(&mut s, "component.create", json!({"name": name}));
+ run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": corner}));
+ }
+ run(
+ &mut s,
+ "joint.create",
+ json!({"type": "revolute", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Arm", "point": [0,0,0]}, "flip": true, "name": "Pivot"}),
+ );
+ run(&mut s, "contact.create", json!({"occurrences": ["Arm", "Blocker"]}));
+ let result = run(&mut s, "joint.drive", json!({"joint": "Pivot", "value": "180 deg"}));
+ assert!(result["stopped_by_contact"].is_object(), "{result}");
+ let angle = s.doc.assembly.joints.iter().find(|j| j.name == "Pivot").unwrap().values[0];
+ assert!(angle > 60_f64.to_radians() && angle < 90_f64.to_radians(), "angle={angle}");
+ let arm = solvecraft_doc::apply_point(&solvecraft_doc::joints::occurrence_world(&s.doc, occurrence(&s, "Arm")), Vec3::new(22.0, 0.0, 0.0));
+ assert!(arm.y > 20.0 && arm.x > 2.0, "{arm:?}");
+}
+
+#[test]
+fn linked_slider_uses_effective_travel_for_contact() {
+ let mut s = slider();
+ run(&mut s, "component.activate", json!({"component": 0}));
+ run(&mut s, "component.create", json!({"name": "Input"}));
+ run(
+ &mut s,
+ "joint.create",
+ json!({"type": "slider", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Input", "point": [0,0,0]}, "flip": true, "name": "Input Joint"}),
+ );
+ run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": 1}));
+ run(&mut s, "joint.motion_link", json!({"a": "Input Joint", "b": "Rail", "ratio": 10}));
+ let result = run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": -3}));
+ assert!(result["stopped_by_contact"].is_object(), "{result}");
+ let input = s.doc.assembly.joints.iter().find(|j| j.name == "Input Joint").unwrap().values[0];
+ assert!(input.abs() < 2e-4, "{input}");
+}
+
+#[test]
+fn motion_play_and_sparse_export_share_swept_contact_checks() {
+ let mut s = slider();
+ run(&mut s, "motion.study", json!({"name": "Crossing", "steps": 100, "keys": [{"joint": "Rail", "points": [[0,10], [100,-30]]}]}));
+ let doc = s.doc.clone();
+ let exported = run(&mut s, "motion.export", json!({"study": "Crossing", "samples": 2}));
+ assert!(std::sync::Arc::ptr_eq(&doc, &s.doc), "export leaves document untouched");
+ assert!(exported["samples"][1]["stopped_by_contact"].is_object(), "{exported}");
+ let played = run(&mut s, "motion.play", json!({"study": "Crossing", "step": 100}));
+ assert!(played["stopped_by_contact"].is_object(), "{played}");
+ assert!(rail_value(&s).abs() < 2e-3);
+}
+
+#[test]
+fn over_budget_sweep_errors_and_restores_document() {
+ let mut s = slider();
+ let doc = s.doc.clone();
+ let error = s.execute("joint.drive", &json!({"joint": "Rail", "value": -100000})).unwrap_err();
+ assert!(error.to_string().contains("sampling budget"), "{error}");
+ assert_eq!(*s.doc, *doc);
+ assert!((rail_value(&s) - 10.0).abs() < 1e-6);
+}
+
+#[test]
+fn thin_tilted_plates_cannot_cross_between_axis_bound_samples() {
+ let mut s = Session::default();
+ let normal = solvecraft_doc::apply_vector(&solvecraft_doc::rigid(Vec3::ZERO, Vec3::ZERO, Vec3::new(1.0, 1.0, 0.0), 55_f64.to_radians()), Vec3::Z);
+ for name in ["Plate A", "Plate B"] {
+ run(&mut s, "component.activate", json!({"component": 0}));
+ run(&mut s, "component.create", json!({"name": name}));
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 0.05, "corner": [-5,-5,-0.025], "body_name": name}));
+ run(&mut s, "solid.move", json!({"bodies": [name], "axis": [1,1,0], "angle": "55 deg"}));
+ }
+ let (a, b) = (occurrence(&s, "Plate A"), occurrence(&s, "Plate B"));
+ run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": normal * 2.0}));
+ run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
+ run(
+ &mut s,
+ "joint.as_built",
+ json!({"type": "slider", "a": "Plate A", "b": "Plate B", "at": {"occurrence": 0, "point": [0,0,0], "z": normal}, "name": "Slide"}),
+ );
+ run(&mut s, "contact.create", json!({"occurrences": ["Plate A", "Plate B"]}));
+ let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -4}));
+ assert!(result["stopped_by_contact"].is_object(), "{result}");
+ let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
+ assert!(value > -2.0 && value < -1.9, "{value}");
+}
+
+#[test]
+fn ordinary_curved_bodies_keep_a_practical_sampling_step() {
+ for curved in ["sphere", "fillet"] {
+ let mut s = Session::default();
+ run(&mut s, "component.create", json!({"name": "Blocker"}));
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0,-5,-5]}));
+ run(&mut s, "component.activate", json!({"component": 0}));
+ run(&mut s, "component.create", json!({"name": "Curved"}));
+ if curved == "sphere" {
+ run(&mut s, "solid.sphere", json!({"radius": 5, "center": [20,0,0]}));
+ } else {
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [15,-5,-5]}));
+ run(&mut s, "solid.fillet", json!({"edges": [[15,-5,0]], "radius": 2}));
+ }
+ run(
+ &mut s,
+ "joint.as_built",
+ json!({"type": "slider", "a": 0, "b": "Curved", "at": {"occurrence": 0, "point": [0,0,0], "z": [1,0,0]}, "name": "Slide"}),
+ );
+ run(&mut s, "contact.create", json!({"occurrences": ["Blocker", "Curved"]}));
+ let geometry = super::ContactGeometry::new(&s).unwrap();
+ assert!(geometry.step > 1.0, "{curved}: tessellation must not force tiny steps: {}", geometry.step);
+ let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -30}));
+ assert!(result["stopped_by_contact"].is_object(), "{curved}: {result}");
+ let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
+ assert!(value > -5.02 && value < -4.9, "{curved}: {value}");
+ }
+}
+
+#[test]
+fn initially_overlapping_assembly_can_move_out_for_recovery() {
+ let mut s = slider();
+ run(&mut s, "contact.disable_all", json!({}));
+ run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
+ run(&mut s, "contact.enable_sets", json!({"enabled": true}));
+ assert!(!run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
+ let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
+ assert!(result["stopped_by_contact"].is_null(), "recovery must not stick: {result}");
+ assert!((rail_value(&s) - 10.0).abs() < 1e-6);
+ assert!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
+}
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index cdfcd4cc..ddf62105 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -296,36 +296,111 @@ fn reorder(s: &mut Session, p: &Value) -> Result {
Ok(json!({"feature": id, "position": to}))
}
+fn input_keys(kind: &FeatureKind) -> Vec<(String, solvecraft_doc::expr::Kind)> {
+ let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
+ if let FeatureKind::Fillet { style, .. } = kind {
+ if let Some((key, _)) = keys.first_mut() {
+ *key = "fillet.radius".into();
+ }
+ if let solvecraft_doc::FilletStyle::Variable { mid, .. } = style {
+ for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
+ *key = format!("fillet.mid.{}", position.to_bits());
+ }
+ }
+ }
+ keys
+}
+
fn redefine(s: &mut Session, p: &Value) -> Result {
let cmd = "timeline.redefine";
let id = feature_id(s, p.get("feature"), cmd)?;
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
- if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") {
+ // Only modeling constructors may run here. In particular, a scratch session does not
+ // isolate filesystem writes performed by export commands.
+ let constructor = super::features::COMMANDS
+ .iter()
+ .chain(super::features_more::COMMANDS)
+ .chain(super::construct::COMMANDS)
+ .chain(super::surface::COMMANDS)
+ .any(|candidate| candidate.id == spec.id)
+ || matches!(
+ spec.id,
+ "sheet.flange"
+ | "sheet.hem"
+ | "sheet.fold"
+ | "sheet.unfold"
+ | "sheet.refold"
+ | "sheet.convert"
+ | "plastic.boss"
+ | "plastic.lip"
+ | "plastic.snap_fit"
+ | "plastic.rest"
+ );
+ if !constructor {
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
}
- let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
+ let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
- let marker = s.doc.marker;
- // Build the new definition where the feature sits, against the model before it.
- s.doc_mut().marker = Some(idx);
- s.refresh();
- let r = (spec.run)(s, ¶ms);
- let made = r.as_ref().ok().and_then(|v| v.get("feature")).and_then(Value::as_u64);
- let new = made.and_then(|nid| s.doc.feature(nid).cloned());
- if let Some(nid) = made {
- s.doc_mut().features.retain(|f| f.id != nid);
+ if !old.participants.is_empty()
+ && let Some(parameters) = params.as_object_mut()
+ {
+ parameters.entry("participants").or_insert_with(|| json!(old.participants));
}
- s.doc_mut().marker = marker;
- r?;
- let new = new.ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
+ // Run the constructor on a scratch session: failed edits must not disturb sketch mode,
+ // selections, component authoring state or the original feature's definition.
+ let mut trial = s.scratch();
+ trial.active_component = old.component;
+ trial.active_sketch = None;
+ trial.selection = s.selection.clone();
+ trial.doc_mut().marker = Some(idx);
+ trial.refresh();
+ (spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
+ let r = (spec.run)(&mut trial, ¶ms)?;
+ let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
+ let new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
}
- if let Some(slot) = s.doc_mut().feature_mut(id) {
- slot.kind = new.kind;
+ // Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
+ // Keep each existing model parameter attached to its semantic input, rather than its
+ // old index. Constructor-assigned names for new inputs cannot reuse the old dimensions.
+ let mut old_inputs: Vec<_> = input_keys(&old.kind).into_iter().zip(&old.param_names).collect();
+ let param_names: Vec = input_keys(&new.kind)
+ .iter()
+ .enumerate()
+ .map(|(i, (label, kind))| {
+ if let Some(k) = old_inputs.iter().position(|((old_label, old_kind), _)| old_label == label && old_kind == kind) {
+ old_inputs.remove(k).1.clone()
+ } else {
+ new.param_names.get(i).cloned().unwrap_or_default()
+ }
+ })
+ .collect();
+ trial.doc_mut().features.retain(|f| f.id != made);
+ let added = trial.doc.features.len().saturating_sub(s.doc.features.len());
+ trial.doc_mut().marker = s.doc.marker.map(|marker| if marker > idx { marker.saturating_add(added) } else { marker });
+ if made == s.doc.next_id && trial.doc.next_id == made.saturating_add(1) {
+ trial.doc_mut().next_id = s.doc.next_id;
}
+ if let Some(slot) = trial.doc_mut().feature_mut(id) {
+ slot.kind = new.kind;
+ slot.edge_refs = new.edge_refs;
+ slot.edge_names = new.edge_names;
+ slot.face_names = new.face_names;
+ slot.param_names = param_names;
+ if params.get("participants").is_some() {
+ slot.participants = new.participants;
+ }
+ }
+ // New optional inputs need model parameter names, while existing dimensions keep theirs.
+ trial.doc_mut().name_feature_inputs(id);
+ trial.refresh();
+ if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) {
+ return Err(EngineError::Other(e));
+ }
+ s.doc = trial.doc;
s.refresh();
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
@@ -344,3 +419,7 @@ fn dependents(s: &mut Session, p: &Value) -> Result {
let broken: Vec = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect();
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
}
+
+#[cfg(test)]
+#[path = "edit_tests.rs"]
+mod tests;
diff --git a/crates/engine/src/cmd/edit_tests.rs b/crates/engine/src/cmd/edit_tests.rs
new file mode 100644
index 00000000..bb289609
--- /dev/null
+++ b/crates/engine/src/cmd/edit_tests.rs
@@ -0,0 +1,268 @@
+//! Edit Feature must replace the selections as well as the numerical definition.
+
+use serde_json::{Value, json};
+
+use crate::{Sel, Session};
+
+fn run(s: &mut Session, id: &str, params: Value) -> Value {
+ s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
+}
+
+#[test]
+fn redefining_a_blend_replaces_persistent_edge_selection_and_survives_resize() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
+ run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 2, "name": "Round"}));
+ let round_id = s.doc.find_feature("Round").unwrap().id;
+ let body_name = s.model.state().bodies[0].name.clone();
+ s.doc_mut().feature_mut(round_id).unwrap().participants = vec![body_name];
+ s.refresh();
+ let old = s.doc.find_feature("Round").unwrap().clone();
+ let before = (*s.doc).clone();
+ run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[0, 15, 20]], "radius": 2}}));
+ let edited = s.doc.find_feature("Round").unwrap().clone();
+ assert_eq!(edited.id, old.id);
+ assert_eq!(edited.param_names, old.param_names);
+ assert_eq!(edited.participants, old.participants);
+ assert_ne!(edited.edge_names, old.edge_names, "the new edge must be the reference used on recompute");
+ assert_ne!(edited.edge_refs, old.edge_refs);
+ assert_eq!(s.doc.next_id, before.next_id, "temporary definitions must not consume feature ids");
+ run(&mut s, "edit.undo", json!({}));
+ assert_eq!(*s.doc, before);
+ run(&mut s, "edit.redo", json!({}));
+ assert_eq!(s.doc.find_feature("Round").unwrap().edge_names, edited.edge_names);
+ run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60", "height": "25"}}));
+ let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
+ let expected = 60.0 * 30.0 * 25.0 - (4.0 - std::f64::consts::PI) * 30.0;
+ assert!((volume - expected).abs() < expected * 1e-4, "{volume} != {expected}");
+ assert!(s.model.result(old.id).unwrap().warning.is_none());
+}
+
+#[test]
+fn redefining_preserves_body_names_participants_and_marker_without_temporary_features() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base", "body_name": "Housing"}));
+ run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0], "name": "Other"}));
+ let id = s.doc.find_feature("Base").unwrap().id;
+ run(&mut s, "timeline.rollback", json!({"position": 1}));
+ let before = s.doc.feature(id).unwrap().clone();
+ run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.box", "params": {"length": 25, "width": 10, "height": 5}}));
+ let after = s.doc.feature(id).unwrap();
+ assert_eq!(s.doc.features.len(), 2);
+ assert_eq!(s.doc.marker, Some(1));
+ assert_eq!(after.body_names, before.body_names);
+ assert_eq!(after.participants, before.participants);
+ assert_eq!(after.param_names, before.param_names);
+ assert_eq!(after.name, before.name);
+ assert!(s.model.state().body("Housing").is_some());
+}
+
+#[test]
+fn failed_or_non_feature_redefinitions_leave_session_and_history_intact() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base"}));
+ run(&mut s, "sketch.create", json!({"plane": "XY"}));
+ s.selection = vec![Sel::Feature { id: s.doc.find_feature("Base").unwrap().id }];
+ let doc = s.doc.clone();
+ let sketch = s.active_sketch;
+ let selection = s.selection.clone();
+ let undo = s.undo.len();
+ for (command, params) in [
+ ("solid.cylinder", json!({"radius": 5, "height": 10})),
+ ("solid.box", json!({"length": "unknown_parameter", "width": 10, "height": 5})),
+ ("file.export", json!({"path": "should-not-be-created.step"})),
+ ("edit.undo", json!({})),
+ ] {
+ assert!(s.execute("timeline.redefine", &json!({"feature": "Base", "command": command, "params": params})).is_err());
+ assert_eq!(*s.doc, *doc, "{command}");
+ assert_eq!(s.active_sketch, sketch, "{command}");
+ assert_eq!(s.selection, selection, "{command}");
+ assert_eq!(s.undo.len(), undo, "{command}");
+ }
+}
+
+#[test]
+fn redefining_an_extrude_names_new_optional_inputs() {
+ let mut s = Session::default();
+ run(&mut s, "sketch.create", json!({"plane": "XY"}));
+ run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
+ run(&mut s, "sketch.finish", json!({}));
+ run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Pad"}));
+ let distance = s.doc.find_feature("Pad").unwrap().param_names[0].clone();
+ run(&mut s, "timeline.redefine", json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "start_offset": 2}}));
+ let f = s.doc.find_feature("Pad").unwrap();
+ assert_eq!(f.param_names.len(), f.kind.inputs().len());
+ assert_eq!(f.param_names[0], distance);
+ assert!(f.param_names.iter().all(|name| !name.is_empty()));
+ assert_eq!(s.doc.features.len(), 2);
+ let offset = s.doc.find_feature("Pad").unwrap().param_names.last().unwrap().clone();
+ run(
+ &mut s,
+ "timeline.redefine",
+ json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "distance2": 2, "start_offset": 2}}),
+ );
+ let f = s.doc.find_feature("Pad").unwrap();
+ let inputs = f.kind.inputs();
+ let offset_index = inputs.iter().position(|(label, _, _)| *label == "Start offset").unwrap();
+ assert_eq!(f.param_names[offset_index], offset, "adding Distance 2 must not steal the offset dimension's parameter name");
+ let distance2_index = inputs.iter().position(|(label, _, _)| *label == "Distance 2").unwrap();
+ assert_ne!(f.param_names[distance2_index], offset);
+}
+
+#[test]
+fn redefining_a_shell_replaces_persistent_face_selection() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
+ run(&mut s, "solid.shell", json!({"faces": [[20, 15, 20]], "thickness": 2, "name": "Hollow"}));
+ let before = s.doc.find_feature("Hollow").unwrap().face_names.clone();
+ assert!(!before.is_empty());
+ run(&mut s, "timeline.redefine", json!({"feature": "Hollow", "command": "solid.shell", "params": {"faces": [[20, 15, 0]], "thickness": 2}}));
+ let after = s.doc.find_feature("Hollow").unwrap().face_names.clone();
+ assert!(!after.is_empty());
+ assert_ne!(before, after);
+ run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "30"}}));
+ let id = s.doc.find_feature("Hollow").unwrap().id;
+ assert!(s.model.result(id).unwrap().error.is_none());
+ assert!(s.model.result(id).unwrap().warning.is_none());
+ assert_eq!(s.doc.find_feature("Hollow").unwrap().face_names, after);
+}
+
+#[test]
+fn redefine_rejects_sheet_exports_before_they_touch_any_files() {
+ let mut s = Session::default();
+ run(&mut s, "sketch.create", json!({"plane": "XY"}));
+ run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
+ run(&mut s, "sketch.finish", json!({}));
+ run(&mut s, "sheet.flange", json!({"name": "Plate"}));
+ let doc = s.doc.clone();
+ let base = std::env::temp_dir().join(format!("solvecraft-redefine-export-{}", std::process::id()));
+ std::fs::create_dir_all(&base).unwrap();
+ for command in ["sheet.export_dxf", "sheet.export_bend_table"] {
+ for existing in [false, true] {
+ let path = base.join(format!("{}-{existing}.out", command.replace('.', "-")));
+ if existing {
+ std::fs::write(&path, b"original file").unwrap();
+ } else if path.exists() {
+ std::fs::remove_file(&path).unwrap();
+ }
+ let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"path": path}})).unwrap_err();
+ assert!(error.to_string().contains("does not make a feature"), "{error}");
+ assert_eq!(*s.doc, *doc);
+ if existing {
+ assert_eq!(std::fs::read(&path).unwrap(), b"original file");
+ std::fs::remove_file(&path).unwrap();
+ } else {
+ assert!(!path.exists(), "{command} must not create a file before refusing the edit");
+ }
+ }
+ }
+ for command in ["sheet.manage_rules", "sheet.rules", "sheet.flat_pattern", "parameters.list", "plastic.manage_rules"] {
+ let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"name": "unexpected"}})).unwrap_err();
+ assert!(error.to_string().contains("does not make a feature"), "{error}");
+ assert_eq!(*s.doc, *doc);
+ }
+ std::fs::remove_dir(&base).unwrap();
+}
+
+#[test]
+fn redefine_keeps_allocator_above_component_and_occurrence_ids() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"}));
+ run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [10, 0, 0], "body_name": "Tool"}));
+ run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Tool"], "name": "Join"}));
+ run(
+ &mut s,
+ "timeline.redefine",
+ json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}),
+ );
+ let max_id =
+ s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).max().unwrap();
+ assert!(s.doc.next_id > max_id);
+ run(&mut s, "component.create", json!({"name": "Next", "parent": 0}));
+ run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2}));
+ let ids: Vec =
+ s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).collect();
+ let unique: std::collections::BTreeSet = ids.iter().copied().collect();
+ assert_eq!(ids.len(), unique.len(), "feature, component and occurrence ids must remain unique: {ids:?}");
+}
+
+#[test]
+fn redefine_maps_placement_inputs_in_the_feature_owner_frame() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "name": "RootBox", "body_name": "RootBody"}));
+ let owner = run(&mut s, "component.create", json!({"name": "Owner"}));
+ run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "name": "OwnedBox", "body_name": "OwnedBody"}));
+ run(&mut s, "solid.fillet", json!({"edges": [[12, 0, 4]], "radius": 0.5, "body": "OwnedBody", "name": "OwnerRound"}));
+ run(&mut s, "occurrence.move", json!({"occurrence": owner["occurrence"], "translate": [100, 0, 0]}));
+ let other = run(&mut s, "component.create", json!({"name": "Other", "parent": 0}));
+ run(&mut s, "occurrence.move", json!({"occurrence": other["occurrence"], "translate": [200, 0, 0]}));
+ let active = s.active_component;
+ run(
+ &mut s,
+ "timeline.redefine",
+ json!({"feature": "RootBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [2, 0, 0]}}),
+ );
+ run(
+ &mut s,
+ "timeline.redefine",
+ json!({"feature": "OwnedBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [110, 0, 0]}}),
+ );
+ run(
+ &mut s,
+ "timeline.redefine",
+ json!({"feature": "OwnerRound", "command": "solid.fillet", "params": {"edges": [[112, 0, 4]], "radius": 0.75, "body": "OwnedBody"}}),
+ );
+ assert_eq!(s.active_component, active, "editing must not activate the owner in the real session");
+ assert_eq!(s.doc.find_feature("OwnedBox").unwrap().component, owner["component"].as_u64().unwrap());
+ let root = run(&mut s, "inspect.measure", json!({"bodies": ["RootBody"]}));
+ let owned = run(&mut s, "inspect.measure", json!({"bodies": ["OwnedBody"]}));
+ assert!((root["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 2.0).abs() < 1e-8);
+ assert!((owned["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 110.0).abs() < 1e-8);
+}
+
+#[test]
+fn redefining_fillet_style_keeps_primary_parameter_and_downstream_references() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
+ run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 2, "name": "Round"}));
+ let parameter = s.doc.find_feature("Round").unwrap().param_names[0].clone();
+ run(&mut s, "solid.box", json!({"length": parameter, "width": 2, "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
+ run(
+ &mut s,
+ "timeline.redefine",
+ json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 3, "type": "chord"}}),
+ );
+ assert_eq!(s.doc.find_feature("Round").unwrap().param_names[0], parameter);
+ let dependent = s.doc.find_feature("Dependent").unwrap().id;
+ assert!(s.model.result(dependent).unwrap().error.is_none());
+ let value = run(&mut s, "expr.evaluate", json!({"expression": parameter}));
+ assert_eq!(value["value"], 3.0);
+}
+
+#[test]
+fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
+ run(
+ &mut s,
+ "solid.fillet",
+ json!({"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.25, 3], [0.75, 4]], "name": "Round"}),
+ );
+ let parameters = s.doc.find_feature("Round").unwrap().param_names.clone();
+ run(&mut s, "solid.box", json!({"length": parameters[2], "width": parameters[3], "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
+ run(
+ &mut s,
+ "timeline.redefine",
+ json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.1, 2.5], [0.25, 3], [0.75, 4]]}}),
+ );
+ let edited = s.doc.find_feature("Round").unwrap();
+ assert_eq!(edited.param_names[0..2], parameters[0..2]);
+ assert_eq!(edited.param_names[3..5], parameters[2..4]);
+ assert!(!parameters.contains(&edited.param_names[2]), "the inserted control needs a new dimension");
+ let dependent = s.doc.find_feature("Dependent").unwrap().id;
+ assert!(s.model.result(dependent).unwrap().error.is_none());
+ let length = run(&mut s, "expr.evaluate", json!({"expression": parameters[2]}));
+ let width = run(&mut s, "expr.evaluate", json!({"expression": parameters[3]}));
+ assert_eq!(length["value"], 3.0);
+ assert_eq!(width["value"], 4.0);
+}
diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs
index 96541ac7..42bb6b66 100644
--- a/crates/engine/src/cmd/motion.rs
+++ b/crates/engine/src/cmd/motion.rs
@@ -12,6 +12,9 @@ use super::joints::{joint_id, occurrence_id, resolve, value};
use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
+#[path = "contact_sweep.rs"]
+mod contact_sweep;
+
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
@@ -87,7 +90,11 @@ fn placed(s: &Session) -> Vec<(Vec, Body)> {
continue;
}
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) {
- if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, m) {
+ // Moving a body into another component preserves its world placement using an
+ // offset from its authoring frame. Contact and explode centres must use that
+ // same offset as display and physical properties.
+ let frame = s.doc.body_offsets.get(&b.name).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
+ if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) {
out.push((path.clone(), t));
}
}
@@ -131,8 +138,11 @@ pub fn collisions(s: &Session) -> Vec<(String, String, f64)> {
/// tested against the other closed mesh: inside by ray parity, and deeper than the tolerance.
fn overlap(a: &Body, b: &Body) -> Option {
let (ma, mb) = (a.tessellate((a.size() * 1e-3).max(1e-3)).ok()?, b.tessellate((b.size() * 1e-3).max(1e-3)).ok()?);
+ mesh_overlap(&ma, &mb, 1e-4 * a.size().max(b.size()).max(1.0))
+}
+
+fn mesh_overlap(ma: &solvecraft_geom::Mesh, mb: &solvecraft_geom::Mesh, tol: f64) -> Option {
let (ba, bb) = (ma.bounds(), mb.bounds());
- let tol = 1e-4 * a.size().max(b.size()).max(1.0);
if ba.max.x < bb.min.x + tol
|| bb.max.x < ba.min.x + tol
|| ba.max.y < bb.min.y + tol
@@ -142,7 +152,7 @@ fn overlap(a: &Body, b: &Body) -> Option {
{
return None;
}
- let depth = deepest(&ma, &mb, tol).max(deepest(&mb, &ma, tol));
+ let depth = deepest(ma, mb, tol).max(deepest(mb, ma, tol));
(depth > tol).then_some(depth)
}
@@ -247,9 +257,12 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
/// Set joint values, stopping short where contact sets would collide: the values are moved
/// from where they are toward `want` as far as they go without a collision.
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec)]) -> Result {
+ let baseline = s.doc.clone();
let start: Vec<(u64, Vec)> =
want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect();
let set = |s: &mut Session, t: f64| {
+ // Trial order must not affect free anchors or linked joint placements.
+ s.doc = baseline.clone();
let doc = s.doc_mut();
for ((id, a), (_, b)) in start.iter().zip(want) {
if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) {
@@ -260,25 +273,45 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec)]) -> Result = want
.iter()
diff --git a/crates/engine/src/cmd/motion_tests.rs b/crates/engine/src/cmd/motion_tests.rs
index dbf1f062..866adbe7 100644
--- a/crates/engine/src/cmd/motion_tests.rs
+++ b/crates/engine/src/cmd/motion_tests.rs
@@ -71,6 +71,40 @@ fn contact_sets_stop_a_driven_joint_at_touching() {
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
}
+#[test]
+fn contact_uses_preserved_body_placements_after_component_reassignment() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
+ let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
+ run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
+ run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
+ run(&mut s, "component.activate", json!({"component": "root"}));
+ let probe = run(&mut s, "component.create", json!({"name": "Probe"}));
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 0, 0]}));
+ run(&mut s, "contact.create", json!({"occurrences": [component["occurrence"], probe["occurrence"]]}));
+ let collisions = run(&mut s, "contact.check", json!({}));
+ assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(1), "body stayed at x=0: {collisions}");
+ run(&mut s, "occurrence.move", json!({"occurrence": probe["occurrence"], "translate": [100, 0, 0]}));
+ let collisions = run(&mut s, "contact.check", json!({}));
+ assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(0), "probe moved away: {collisions}");
+}
+
+#[test]
+fn automatic_explode_centres_follow_reassigned_bodies() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
+ let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
+ run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
+ run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
+ run(&mut s, "component.activate", json!({"component": "root"}));
+ run(&mut s, "component.create", json!({"name": "Neighbour"}));
+ run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
+ let exploded = run(&mut s, "explode.create", json!({"scale": 1}));
+ let moves = exploded["moves"].as_array().unwrap();
+ let carrier = moves.iter().find(|m| m["occurrence"] == component["occurrence"]).unwrap();
+ assert!((carrier["translate"][0].as_f64().unwrap() + 10.0).abs() < 1e-6, "{exploded}");
+}
+
#[test]
fn motion_study_plays_and_exports() {
let mut s = rail();
diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs
index bbaf5333..39976b67 100644
--- a/crates/engine/src/cmd/sheet.rs
+++ b/crates/engine/src/cmd/sheet.rs
@@ -45,6 +45,9 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
.noundo()
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
+ CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
+ .noundo()
+ .params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"),
];
fn points(p: &Value, k: &str, cmd: &str) -> Result> {
@@ -142,8 +145,9 @@ fn fold(s: &mut Session, p: &Value) -> Result {
let cmd = "sheet.fold";
let (sketch, curve, a, b) = match p.get("points") {
Some(v) => {
- let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::>()).unwrap_or_default();
- let [a, b] = pts[..] else { return Err(bad(cmd, "`points` must be two points [x, y, z]")) };
+ let pts = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`points` must be two points [x, y, z]"))?;
+ let a = pts.first().and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?;
+ let b = pts.get(1).and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?;
(None, None, a, b)
}
None => {
@@ -306,6 +310,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result {
w(2, "TABLES");
w(0, "TABLE");
w(2, "LAYER");
+ w(70, "3");
for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] {
w(0, "LAYER");
w(2, name);
@@ -338,17 +343,25 @@ pub fn flat_dxf(sh: &SheetBody) -> Result {
for seg in &h.outer.segs {
match *seg {
solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => {
+ if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 {
+ w(0, "CIRCLE");
+ w(8, "OUTLINE");
+ w(10, &f(center.x));
+ w(20, &f(center.y));
+ w(40, &f(radius));
+ continue;
+ }
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
w(0, "ARC");
w(8, "OUTLINE");
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
- w(50, &f(a0.to_degrees()));
- w(51, &f(a1.to_degrees()));
+ w(50, &f(a0.to_degrees().rem_euclid(360.0)));
+ w(51, &f(a1.to_degrees().rem_euclid(360.0)));
}
other => {
- let pts: Vec = (0..=8).map(|k| other.point_at(k as f64 / 8.0)).collect();
+ let pts = other.polyline(1e-3);
for p in pts.windows(2) {
line(&mut w, "OUTLINE", p[0], p[1]);
}
@@ -375,6 +388,50 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result {
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
}
+fn export_bend_table(s: &mut Session, p: &Value) -> Result {
+ let cmd = "sheet.export_bend_table";
+ let format = str_(p, "format").unwrap_or("csv").to_ascii_lowercase();
+ if !matches!(format.as_str(), "csv" | "json") {
+ return Err(bad(cmd, "`format` must be csv or json"));
+ }
+ let sh = sheet_of(s, p, cmd)?;
+ let table = flat_json(&sh)?;
+ let text = if format == "json" {
+ serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
+ } else {
+ // Always quote text fields: body/rule names may include commas, quotes or newlines.
+ let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
+ let mut csv =
+ "body,rule,thickness_mm,k_factor,bend,angle_deg,direction,radius_mm,allowance_mm,length_mm,start_x_mm,start_y_mm,end_x_mm,end_y_mm\r\n"
+ .to_string();
+ for b in sh.bends() {
+ csv.push_str(&format!(
+ "{},{},{:.9},{:.9},{},{:.9},{},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9}\r\n",
+ quote(&sh.body),
+ quote(&sh.rule),
+ sh.t,
+ sh.k,
+ b.index,
+ b.angle_deg,
+ if b.up { "up" } else { "down" },
+ b.radius,
+ b.allowance,
+ b.length,
+ b.start.x,
+ b.start.y,
+ b.end.x,
+ b.end.y
+ ));
+ }
+ csv
+ };
+ if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
+ solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ return Ok(json!({"path": path, "format": format, "bends": sh.bends().len()}));
+ }
+ Ok(json!({"format": format, "text": text, "bends": sh.bends().len()}))
+}
+
#[cfg(test)]
#[path = "sheet_tests.rs"]
mod tests;
diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs
index c5b367be..3bfe4c6b 100644
--- a/crates/engine/src/cmd/sheet_tests.rs
+++ b/crates/engine/src/cmd/sheet_tests.rs
@@ -344,3 +344,80 @@ fn fold_keeps_the_larger_side_either_way() {
}
}
}
+
+#[test]
+fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
+ let mut s = Session::default();
+ run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "thickness": 2, "bend_radius": 3, "k_factor": 0.4}));
+ base(&mut s, 100.0, 60.0);
+ run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20}));
+ let undo = s.undo.len();
+ let csv = run(&mut s, "sheet.export_bend_table", json!({}));
+ let text = csv["text"].as_str().unwrap();
+ assert!(text.starts_with("body,rule,thickness_mm,k_factor,bend,angle_deg,direction,"));
+ assert!(text.contains("\"Shop, \"\"steel\"\"\""), "{text}");
+ assert_eq!(text.lines().count(), 2);
+ assert_eq!(csv["bends"], 1);
+ let exported = run(&mut s, "sheet.export_bend_table", json!({"format": "JSON"}));
+ let table: Value = serde_json::from_str(exported["text"].as_str().unwrap()).unwrap();
+ assert_eq!(table["thickness"], 2.0);
+ assert_eq!(table["bends"][0]["direction"], "up");
+ assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 3.8).abs() < 1e-9);
+ assert_eq!(s.undo.len(), undo, "export does not create an undo step");
+ run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "k_factor": 0.5}));
+ let updated = run(&mut s, "sheet.export_bend_table", json!({"format": "json"}));
+ let table: Value = serde_json::from_str(updated["text"].as_str().unwrap()).unwrap();
+ assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 4.0).abs() < 1e-9);
+ assert!(s.execute("sheet.export_bend_table", &json!({"format": "xml"})).is_err());
+ assert!(s.execute("sheet.export_bend_table", &json!({"body": "Missing"})).is_err());
+ let mut empty = Session::default();
+ assert!(empty.execute("sheet.export_bend_table", &json!({})).is_err());
+}
+
+#[test]
+fn malformed_fold_points_are_rejected_atomically() {
+ let mut s = Session::default();
+ base(&mut s, 100.0, 60.0);
+ let before = serde_json::to_value(&s.doc).unwrap();
+ for points in [
+ json!([[40, 0, 2.5], null, [40, 60, 2.5]]),
+ json!([[40, 0, 2.5], [40, 60, 2.5], "junk"]),
+ json!([[40, 0, 2.5], [40, 60]]),
+ json!([[40, 0, 2.5]]),
+ json!("not points"),
+ ] {
+ assert!(s.execute("sheet.fold", &json!({"points": points})).is_err());
+ assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
+ }
+}
+
+#[test]
+fn flat_dxf_preserves_exact_cutout_entities() {
+ use solvecraft_geom::{Loop2, Region2, Seg2, Vec2};
+ let mut s = Session::default();
+ base(&mut s, 100.0, 60.0);
+ let mut sh = s.model.state().sheets[0].clone();
+ sh.holes = vec![Region2 {
+ outer: Loop2 { segs: vec![Seg2::Arc { center: Vec2::new(40.0, 30.0), radius: 5.0, start: -PI, sweep: -2.0 * PI }] },
+ holes: Vec::new(),
+ }];
+ let dxf = super::flat_dxf(&sh).unwrap();
+ assert_eq!(dxf.matches("0\nCIRCLE\n").count(), 1, "{dxf}");
+ assert!(!dxf.contains("0\nARC\n"), "a full circle must not become a zero-length ARC");
+ let read = solvecraft_io::read_dxf(&dxf).unwrap();
+ assert_eq!(read.len(), 5);
+ assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Circle(c, r) if *c == Vec2::new(40.0, 30.0) && (*r - 5.0).abs() < 1e-9)));
+ sh.holes[0].outer = Loop2 {
+ segs: vec![
+ Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(30.0, 10.0) },
+ Seg2::Arc { center: Vec2::new(20.0, 10.0), radius: 10.0, start: 0.0, sweep: PI },
+ ],
+ };
+ let dxf = super::flat_dxf(&sh).unwrap();
+ assert_eq!(dxf.matches("0\nLINE\n").count(), 5, "four outline lines and one exact cutout line");
+ assert_eq!(dxf.matches("0\nARC\n").count(), 1);
+ assert!(dxf.contains("50\n0.000000\n51\n180.000000\n"));
+ let read = solvecraft_io::read_dxf(&dxf).unwrap();
+ assert_eq!(read.len(), 6);
+ assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Arc { c, a, b } if *c == Vec2::new(20.0, 10.0) && a.dist(Vec2::new(30.0, 10.0)) < 1e-9 && b.dist(Vec2::new(10.0, 10.0)) < 1e-9)));
+}
diff --git a/crates/engine/src/cmd/sketch_freeform.rs b/crates/engine/src/cmd/sketch_freeform.rs
index 0ec0e92f..f1e884d0 100644
--- a/crates/engine/src/cmd/sketch_freeform.rs
+++ b/crates/engine/src/cmd/sketch_freeform.rs
@@ -12,7 +12,7 @@ use crate::params::bad;
/// Exact pieces of a free-form curve (polyline lines for the few kinds without them).
fn pieces(sk: &Sketch, ci: usize) -> Vec {
- sk.exact_segs(ci).unwrap_or_else(|| sk.polyline(ci).windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect())
+ sk.exact_segs(ci).unwrap_or_else(|| sk.segs(ci))
}
/// Position along the pieces (piece index + local parameter) closest to `p`, and the distance.
@@ -44,7 +44,7 @@ fn project(sk: &Sketch, ci: usize, p: Vec2) -> Vec2 {
}
/// Where other curves meet free-form curve `ci`: (position along it, the point, the other curve).
-fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
+fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2], only_freeform: bool) -> Vec<(f64, Vec2, usize)> {
let mine = sk.polyline(ci);
let tol = 1e-6;
let mut out: Vec<(f64, Vec2, usize)> = Vec::new();
@@ -57,8 +57,8 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
let (t, u) = ((c - a).cross(s) / den, (c - a).cross(r) / den);
((-1e-9..=1.0 + 1e-9).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&u)).then(|| a + r * t)
};
- for (oi, _) in sk.curves.iter().enumerate() {
- if oi == ci {
+ for (oi, curve) in sk.curves.iter().enumerate() {
+ if oi == ci || (only_freeform && !curve.kind.is_freeform()) {
continue;
}
let other = sk.polyline(oi);
@@ -90,7 +90,7 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
q = n;
}
let (u, d) = locate(segs, q);
- if d < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) {
+ if d < tol && project(sk, oi, q).dist(q) < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) {
out.push((u, q, oi));
}
}
@@ -99,6 +99,15 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
out
}
+/// Crossings for an analytic target with free-form cutters. Analytic-to-analytic crossings
+/// remain in the shape intersection routine; this supplies the missing mixed pairs.
+pub(super) fn analytic_cut_points(sk: &Sketch, ci: usize) -> Vec<(Vec2, usize)> {
+ if !sk.curves.iter().any(|c| c.kind.is_freeform()) {
+ return Vec::new();
+ }
+ cuts(sk, ci, &pieces(sk, ci), true).into_iter().map(|(_, q, oi)| (q, oi)).collect()
+}
+
/// Exact sub-pieces between positions `ua` < `ub` (positions may run past the end on closed
/// curves; they wrap).
fn span(segs: &[Seg2], ua: f64, ub: f64) -> Vec {
@@ -176,18 +185,45 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option, end: Option<
/// Trim (`remove`) or break a free-form curve at the crossings around `at`.
pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, cmd: &str) -> Result {
+ modify(sk, ci, at, remove, None, cmd)
+}
+
+/// Break at explicit positions, projected onto the exact curve. Closed curves can be opened
+/// at one point; open curves require interior positions.
+pub(super) fn break_at_points(sk: &mut Sketch, ci: usize, points: &[Vec2], cmd: &str) -> Result {
+ modify(sk, ci, Vec2::ZERO, false, Some(points), cmd)
+}
+
+fn modify(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, points: Option<&[Vec2]>, cmd: &str) -> Result {
let segs = pieces(sk, ci);
if segs.is_empty() {
return Err(bad(cmd, "the curve has no shape"));
}
let n = segs.len() as f64;
let closed = segs.first().zip(segs.last()).is_some_and(|(a, b)| a.start().dist(b.end()) < 1e-9);
- let cs: Vec<(f64, Vec2, usize)> = cuts(sk, ci, &segs).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).collect();
+ let cs: Vec<(f64, Vec2, Option)> = if let Some(points) = points {
+ let mut positions: Vec = points.iter().map(|q| locate(&segs, *q).0).collect();
+ if positions.iter().any(|u| !u.is_finite() || (!closed && (*u <= 1e-9 || *u >= n - 1e-9))) {
+ return Err(bad(cmd, "break points must project inside the curve, away from its ends"));
+ }
+ if closed {
+ for u in &mut positions {
+ if *u >= n - 1e-9 {
+ *u = 0.0;
+ }
+ }
+ }
+ positions.sort_by(f64::total_cmp);
+ positions.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
+ positions.into_iter().map(|u| (u, point_at(&segs, u), None)).collect()
+ } else {
+ cuts(sk, ci, &segs, false).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).map(|(u, q, c)| (u, q, Some(c))).collect()
+ };
let (uq, _) = locate(&segs, at);
let ends = sk.curves.get(ci).and_then(|c| c.kind.ends());
let construction = sk.curves.get(ci).is_some_and(|c| c.construction);
// Intervals between consecutive cuts (closed curves wrap round).
- let mut bounds: Vec<(f64, Option)> = cs.iter().map(|c| (c.0, Some(c.2))).collect();
+ let mut bounds: Vec<(f64, Option)> = cs.iter().map(|c| (c.0, c.2)).collect();
if bounds.is_empty() {
if remove {
sk.remove_curves(&[ci]);
@@ -239,7 +275,7 @@ pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool,
// Cut points stay on the curves that cut there.
for ((_, _, oi), (_, pi)) in cs.iter().zip(&cut_pt) {
let used = sk.curves.iter().any(|c| c.kind.uses(*pi));
- if used {
+ if used && let Some(oi) = oi {
add_c(sk, ConstraintKind::PointOnCurve { p: *pi, c: *oi })?;
}
}
diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs
index f79c3697..69575862 100644
--- a/crates/engine/src/cmd/sketch_modify.rs
+++ b/crates/engine/src/cmd/sketch_modify.rs
@@ -9,7 +9,7 @@ use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, interse
use super::sketch::{add_c, edit, ids_of};
use super::{CommandSpec, in_sketch};
-use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2};
+use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
@@ -54,7 +54,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY")
.icon("break")
.enabled(in_sketch)
- .params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"),
+ .params("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)"),
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("scale")
@@ -135,6 +135,13 @@ fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> {
}
}
}
+ for (q, oi) in super::sketch_freeform::analytic_cut_points(sk, ci) {
+ if let Some(t) = on_bounded(&sh, q)
+ && !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0)
+ {
+ out.push((t, q, oi));
+ }
+ }
out
}
@@ -314,17 +321,24 @@ fn trim(s: &mut Session, p: &Value) -> Result {
fn break_cmd(s: &mut Session, p: &Value) -> Result {
let cmd = "sketch.break";
- let (out, info) = edit(s, p, cmd, false, |sk, _| {
+ let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None };
+ let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
- let at = req_vec2(cmd, p, "at")?;
- let r = super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?;
+ let r = if let Some(points) = &points {
+ super::sketch_freeform::break_at_points(sk, ci, points, cmd)?
+ } else {
+ let at = req_vec2(cmd, p, "at")?;
+ super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?
+ };
+ doc.prune_model_params();
return Ok(r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default());
}
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
- let (ts, cutters): (Vec, Vec>) = if let Some(list) = p.get("points").and_then(Value::as_array) {
- let mut v: Vec = list.iter().take(1000).filter_map(vec2).map(|q| sh.param(sh.project(q))).collect();
+ let (ts, cutters): (Vec, Vec>) = if let Some(list) = &points {
+ let mut v: Vec = list.iter().map(|q| sh.param(sh.project(*q))).collect();
v.sort_by(|a, b| a.total_cmp(b));
+ v.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
let n = v.len();
(v, vec![None; n])
} else {
diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs
index 51916014..5f13604d 100644
--- a/crates/engine/src/cmd/sketch_modify_tests.rs
+++ b/crates/engine/src/cmd/sketch_modify_tests.rs
@@ -37,6 +37,102 @@ fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
+#[test]
+fn explicit_freeform_break_preserves_exact_geometry_and_undo() {
+ for (command, params) in [
+ ("sketch.spline.control_point", json!({"points": [[0,0], [0,12], [20,12], [20,0]], "connect": false})),
+ ("sketch.spline.control_point_5", json!({"points": [[0,0], [0,12], [5,20], [12,20], [20,12], [20,0]], "connect": false})),
+ ("sketch.spline.fit_point", json!({"points": [[0,0], [6,12], [14,8], [20,0]], "connect": false})),
+ ("sketch.conic", json!({"start": [0,0], "end": [20,0], "apex": [10,20], "rho": 0.65, "connect": false})),
+ ] {
+ let mut s = new_sketch();
+ let id = ids(&run(&mut s, command, params)["curves"])[0].clone();
+ let before = sketch(&s);
+ let exact = before.exact_segs(before.curve_index(&id).unwrap()).unwrap();
+ let a = exact.first().unwrap().point_at(0.25);
+ let b = exact.last().unwrap().point_at(0.75);
+ let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [b, a, a]}));
+ assert!(ids(&out["curves"]).len() >= 3, "{command}: {out}");
+ let after = sketch(&s);
+ let segs: Vec<_> = after.curves.iter().enumerate().flat_map(|(i, _)| after.exact_segs(i).unwrap_or_default()).collect();
+ for old in &exact {
+ for k in 0..=40 {
+ let q = old.point_at(k as f64 / 40.0);
+ let d = segs.iter().map(|seg| seg.point_at(seg.closest_param(q)).dist(q)).fold(f64::INFINITY, f64::min);
+ assert!(d < 1e-6, "{command}: geometry moved {d} at {q:?}");
+ }
+ }
+ let encoded = serde_json::to_value(&after).unwrap();
+ let restored: solvecraft_sketch::Sketch = serde_json::from_value(encoded).unwrap();
+ assert_eq!(restored.curves.len(), after.curves.len());
+ run(&mut s, "edit.undo", json!({}));
+ assert!(sketch(&s).curve_index(&id).is_some());
+ }
+}
+
+#[test]
+fn explicit_ellipse_break_keeps_closed_profile() {
+ let mut s = new_sketch();
+ let id = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [10,0], "minor": [0,4]}))["curves"])[0].clone();
+ let area = profiles(&s)[0];
+ let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,0], [0,4], [10,0]]}));
+ assert_eq!(ids(&out["curves"]).len(), 4, "{out}");
+ let after = sketch(&s);
+ assert!(after.curves.iter().all(|c| matches!(c.kind, CurveKind::Conic { .. })));
+ assert_eq!(profiles(&s).len(), 1);
+ assert!((profiles(&s)[0] - area).abs() < 1e-6);
+ run(&mut s, "edit.undo", json!({}));
+ let original = sketch(&s);
+ let q = original.exact_segs(original.curve_index(&id).unwrap()).unwrap()[0].point_at(0.4);
+ let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [q]}));
+ assert_eq!(ids(&out["curves"]).len(), 5, "a single cut inside a quadrant: {out}");
+ assert_eq!(profiles(&s).len(), 1);
+ assert!((profiles(&s)[0] - area).abs() < 1e-6);
+}
+
+#[test]
+fn explicit_break_rejects_bad_points_atomically_and_merges_duplicates() {
+ for freeform in [false, true] {
+ let mut s = new_sketch();
+ let out = if freeform {
+ run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false}))
+ } else {
+ run(&mut s, "sketch.line", json!({"points": [[0,0], [20,0]], "connect": false}))
+ };
+ let id = ids(&out["curves"])[0].clone();
+ let before = serde_json::to_value(sketch(&s)).unwrap();
+ for points in [json!([]), json!("wrong"), json!([[8, 4], ["bad", 0]]), json!([[0, 0]]), json!([[1e20, 0]]), json!(vec![[10, 5]; 1001])] {
+ assert!(s.execute("sketch.break", &json!({"curve": id, "points": points})).is_err());
+ assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before);
+ }
+ let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,7], [10,7]]}));
+ assert_eq!(ids(&out["curves"]).len(), 2, "{out}");
+ }
+}
+
+#[test]
+fn analytic_trim_and_break_use_freeform_cutters() {
+ let mut s = new_sketch();
+ let line = ids(&run(&mut s, "sketch.line", json!({"points": [[-5,5], [25,5]], "connect": false}))["curves"])[0].clone();
+ run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false}));
+ let out = run(&mut s, "sketch.break", json!({"curve": line, "at": [10,5]}));
+ assert_eq!(ids(&out["curves"]).len(), 3, "{out}");
+ let sk = sketch(&s);
+ for id in ids(&out["curves"]) {
+ let shape = sk.shape(sk.curve_index(&id).unwrap()).unwrap();
+ assert!(matches!(shape, solvecraft_sketch::Shape::Line { a, b } if (a.y - 5.0).abs() < 1e-6 && (b.y - 5.0).abs() < 1e-6));
+ }
+
+ let mut s = new_sketch();
+ let circle = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 5}))["curves"])[0].clone();
+ run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [8,0], "minor": [0,3]}));
+ run(&mut s, "sketch.trim", json!({"curve": circle, "at": [5,0]}));
+ let sk = sketch(&s);
+ let ci = sk.curve_index(&circle).unwrap();
+ assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. }));
+ assert!(sk.shape(ci).unwrap().dist(Vec2::new(-5.0, 0.0)) < 1e-6);
+}
+
#[test]
fn trim_extend_break() {
let mut s = new_sketch();
@@ -219,13 +315,9 @@ fn ellipse_splines_conic() {
run(&mut s, "sketch.constraint.coincident", json!({"a": "q", "b": eid}));
let sk = sketch(&s);
let q = sk.point(sk.point_index("q").unwrap()).unwrap();
- let poly = sk.polyline(sk.curve_index(&eid).unwrap());
- let d = poly.windows(2).map(|w| {
- let (a, b) = (w[0], w[1]);
- let t = ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0);
- q.dist(a + (b - a) * t)
- });
- assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}");
+ let exact = sk.exact_segs(sk.curve_index(&eid).unwrap()).unwrap();
+ let distance = exact.iter().map(|seg| q.dist(seg.point_at(seg.closest_param(q)))).fold(f64::INFINITY, f64::min);
+ assert!(distance < 1e-6, "on the exact ellipse: {q:?}");
// Fit spline closed by a line: one profile.
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sid = ids(&sp["curves"])[0].clone();
diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs
index dd0ab8fe..67501f4d 100644
--- a/crates/engine/src/frames.rs
+++ b/crates/engine/src/frames.rs
@@ -345,7 +345,13 @@ impl Drop for Nested {
pub fn to_local(s: &Session, id: &str, params: &Value) -> Option {
if id == "timeline.redefine" {
let cmd = params.get("command")?.as_str()?;
- let inner = to_local(s, cmd, params.get("params")?)?;
+ let key = params.get("feature").and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|id| id.to_string())))?;
+ let feature = s.doc.find_feature(&key)?;
+ // Edit Feature authors in the existing feature's frame, even when another
+ // component is active. Body-relative picks still use the picked body's frame.
+ let mut context = s.scratch();
+ context.active_component = feature.component;
+ let inner = to_local(&context, cmd, params.get("params")?)?;
let mut out = params.clone();
out["params"] = inner;
return Some(out);
diff --git a/crates/engine/src/frames_tests.rs b/crates/engine/src/frames_tests.rs
index 751efdf1..220ab277 100644
--- a/crates/engine/src/frames_tests.rs
+++ b/crates/engine/src/frames_tests.rs
@@ -77,10 +77,14 @@ fn world_picks_map_into_the_component() {
assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material");
// A sketch on the moved top face sits at world z = 10 over the moved block.
run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"}));
- run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2}));
+ run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2, "id": "PadCircle"}));
+ let sketch = run(&mut s, "sketch.inspect", json!({"sketch": "Pad"}));
+ let center = sketch["curves"].as_array().unwrap().iter().find(|c| c["id"] == "PadCircle").unwrap()["center"].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
- let pad = sketch_point_world(&mut s, "Pad", "p1");
- assert!((pad.z - 10.0).abs() < 1e-6 && pad.x > 50.0, "{pad:?}");
+ // A projected boundary's first point is an enumeration detail; check the circle
+ // that was actually authored in the moved component's local sketch coordinates.
+ let pad = sketch_point_world(&mut s, "Pad", ¢er);
+ assert!(near(pad, Vec3::new(70.0, 10.0, 10.0)), "{pad:?}");
let v1 = volume(&mut s);
let h = run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
assert!(volume(&mut s) < v1 - 1.0, "the hole cut material: {h}");
diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs
index e98a3210..4b57d605 100644
--- a/crates/kernel/src/curveblend.rs
+++ b/crates/kernel/src/curveblend.rs
@@ -19,6 +19,144 @@ fn unsupported(msg: &str) -> KernelError {
/// Samples per edge for the contact curves and the blend surface.
const SAMPLES: usize = 24;
+const MAX_SAMPLES: usize = 369;
+const MAX_CHAIN_SAMPLES: usize = 100_000;
+
+struct Fit {
+ surface: mt::Surface,
+ contacts: [mt::Curve; 2],
+ /// Parameter range belonging to this edge (the fit includes neighbouring sections).
+ range: (f64, f64),
+}
+
+struct Guide {
+ sections: Vec,
+ lengths: Vec,
+}
+
+impl Guide {
+ fn new(sections: Vec) -> Result {
+ let mut lengths = vec![0.0];
+ let mut total = 0.0;
+ for pair in sections.windows(2) {
+ let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
+ total += a.c.dist(b.c);
+ lengths.push(total);
+ }
+ if !total.is_finite() || total <= 1e-12 {
+ return Err(unsupported("degenerate rolling-ball spine"));
+ }
+ Ok(Self { sections, lengths })
+ }
+
+ fn anchor(&self, along: f64) -> Option<(Vec3, Vec3)> {
+ if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) {
+ return None;
+ }
+ let distance = self.lengths.last()? * along;
+ let hi = self.lengths.partition_point(|length| *length < distance).clamp(1, self.lengths.len().checked_sub(1)?);
+ let (a, b) = (self.sections.get(hi - 1)?, self.sections.get(hi)?);
+ let (lo, hi_length) = (self.lengths.get(hi - 1)?, self.lengths.get(hi)?);
+ let span = hi_length - lo;
+ if span <= 1e-12 {
+ return None;
+ }
+ let fraction = (distance - lo) / span;
+ Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?))
+ }
+}
+
+/// One interpolation for both the face and its boundary curves. Fitting the contacts
+/// independently gives different end conditions and leaves gaps between the same faces.
+fn fit_sections(sections: &[Vec], k: usize, closed: bool, round: bool) -> Result {
+ use mt::Cut;
+ let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
+ let n = sections.len();
+ let list = sections.get(k).ok_or_else(|| fail("section"))?;
+ let before = usize::from(closed || k > 0) * 2;
+ let mut ext = Vec::with_capacity(list.len() + 4);
+ if before > 0 {
+ let prev = sections.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
+ ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
+ }
+ ext.extend(list.iter().copied());
+ if closed || k + 1 < n {
+ let next = sections.get((k + 1) % n).ok_or_else(|| fail("section"))?;
+ ext.extend(next.iter().skip(1).take(2).copied());
+ }
+ let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::>().ok_or_else(|| fail("section"))?;
+ let mut rows = Vec::with_capacity(3);
+ let mut vknots = None;
+ for j in 0..3 {
+ let xyz: Vec = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.x, c.y, c.z)).collect();
+ let ww: Vec = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.w, 0.0, 0.0)).collect();
+ let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?;
+ let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?;
+ vknots = Some(bx.knot_vec().clone());
+ rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect::>());
+ }
+ let vk = vknots.ok_or_else(|| fail("knots"))?;
+ let den = ext.len().saturating_sub(1) as f64;
+ if den <= 0.0 {
+ return Err(fail("empty sections"));
+ }
+ let range = (before as f64 / den, (before + list.len().saturating_sub(1)) as f64 / den);
+ let contact = |row: usize| -> Result {
+ let points = rows.get(row).cloned().ok_or_else(|| fail("contact row"))?;
+ let bs = mt::BSplineCurve::try_new(vk.clone(), points).map_err(|e| fail(&e.to_string()))?;
+ let mut curve = mt::NurbsCurve::new(bs);
+ if range.0 > 0.0 {
+ curve = curve.cut(range.0);
+ }
+ if range.1 < 1.0 {
+ let _ = curve.cut(range.1);
+ }
+ Ok(mt::Curve::NurbsCurve(curve))
+ };
+ let contacts = [contact(0)?, contact(2)?];
+ let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), vk), rows).map_err(|e| fail(&e.to_string()))?;
+ Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range })
+}
+
+/// Compare the fitted strip with a solved circular section between interpolation nodes.
+/// This checks the supporting contacts as well as the middle of the fillet band.
+fn section_fits(fit: &Fit, section: &Section, along: f64, round: bool, sigma: f64, tolerance: f64) -> bool {
+ use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
+ let Some(ctrl) = section_controls(section, round) else { return false };
+ let v = fit.range.0 + (fit.range.1 - fit.range.0) * along;
+ for u in [0.0, 0.25, 0.5, 0.75, 1.0] {
+ let [a, b, c] = ctrl;
+ let h = a * (1.0 - u) * (1.0 - u) + b * (2.0 * u * (1.0 - u)) + c * u * u;
+ if !h.w.is_finite() || h.w <= 1e-12 {
+ return false;
+ }
+ let exact = Vec3::new(h.x / h.w, h.y / h.w, h.z / h.w);
+ // The input intersection edge may use piecewise leader-curve parameters. A
+ // parameter-speed change is not a geometric error: compare nearest points, with
+ // contacts constrained to the corresponding boundary and all probes to the strip.
+ let (probe_u, probe_v) = if u == 0.0 || u == 1.0 {
+ let Some(contact) = fit.contacts.get(usize::from(u == 1.0)) else { return false };
+ let Some(parameter) = contact.search_nearest_parameter(p3(exact), v, 32) else { return false };
+ (u, parameter.clamp(fit.range.0, fit.range.1))
+ } else {
+ let Some((pu, pv)) = fit.surface.search_nearest_parameter(p3(exact), Some((u, v)), 32) else { return false };
+ (pu.clamp(0.0, 1.0), pv.clamp(fit.range.0, fit.range.1))
+ };
+ let actual = from_p3(fit.surface.subs(probe_u, probe_v));
+ if !actual.is_finite() || actual.dist(exact) > tolerance {
+ return false;
+ }
+ if round && (u == 0.0 || u == 1.0) {
+ let Some(want) = ((section.c - exact) * sigma).normalized() else { return false };
+ let normal = fit.surface.normal(probe_u, probe_v);
+ let Some(normal) = Vec3::new(normal.x, normal.y, normal.z).normalized() else { return false };
+ if normal.dot(want).abs() < 0.5f64.to_radians().cos() {
+ return false;
+ }
+ }
+ }
+ true
+}
/// One cross-section of the blend: ball centre and its contacts with side 1 and side 2.
#[derive(Clone, Copy, Debug)]
@@ -102,6 +240,16 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
/// Blend the closed chain of edges (ids in any order) with radius `r`.
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result {
+ curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES)
+}
+
+fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result {
+ if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) {
+ return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into()));
+ }
+ if !(4..=MAX_SAMPLES).contains(&max_samples) {
+ return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into()));
+ }
let tol = (size * 1e-7).max(1e-9);
let shells = solid.boundaries();
let (si, shell) = shells
@@ -303,16 +451,85 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
vsec.push(s);
}
- let mut esec: Vec> = Vec::with_capacity(n);
- for (k, e) in chain.iter().enumerate() {
- let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
- for j in 1..SAMPLES - 1 {
- let (p, t) = tangent_at(e, j as f64 / (SAMPLES - 1) as f64).ok_or_else(|| KernelError::Failed("tangent".into()))?;
- list.push(solve(&s1, &s2, p, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?);
+ // Uniform refinement preserves a common spacing across neighbouring patches. Work is
+ // capped: a difficult strip returns an error instead of accepting an inaccurate fit.
+ let fit_tolerance = (size * 1e-5).clamp(1e-5, 0.001);
+ // At a seam the contact lies on the seam plane, which need not be square to the
+ // original edge. Its ball centre therefore need not lie in the edge's normal plane.
+ // Interpolate that plane's displacement so the interior sections tend to the exact
+ // seam sections, rather than leaving a finite jump as sampling gets finer.
+ let offsets: Vec<(f64, f64)> = chain
+ .iter()
+ .enumerate()
+ .map(|(k, edge)| {
+ let (p0, t0) = tangent_at(edge, 0.0).ok_or_else(|| KernelError::Failed("tangent".into()))?;
+ let (p1, t1) = tangent_at(edge, 1.0).ok_or_else(|| KernelError::Failed("tangent".into()))?;
+ let start = vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?;
+ let end = vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?;
+ Ok(((start.c - p0).dot(t0), (end.c - p1).dot(t1)))
+ })
+ .collect::>()?;
+ let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result {
+ let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?;
+ let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?;
+ let anchor = p + t * (start * (1.0 - along) + end * along);
+ solve(&s1, &s2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
+ };
+ let mut samples = SAMPLES.min(max_samples);
+ let (esec, fits) = loop {
+ if n.checked_mul(samples).is_none_or(|count| count > MAX_CHAIN_SAMPLES) {
+ return Err(unsupported("curve blend exceeds the section fitting limit"));
}
- list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
- esec.push(list);
- }
+ let mut guides = Vec::with_capacity(n);
+ for (k, e) in chain.iter().enumerate() {
+ let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
+ for j in 1..samples - 1 {
+ list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?);
+ }
+ list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
+ guides.push(Guide::new(list)?);
+ }
+ // Intersection curves inherit their leader's piecewise parameter speed. Fitting
+ // those parameters creates oscillations even on a geometrically smooth spine.
+ // Resample by distance along the rolling centres, projecting each interpolated
+ // centre back onto the exact two offset surfaces before it becomes a section.
+ let uniform_section = |k: usize, along: f64| -> Result {
+ let guide = guides.get(k).ok_or_else(|| KernelError::Failed("spine guide".into()))?;
+ if along == 0.0 {
+ return guide.sections.first().copied().ok_or_else(|| KernelError::Failed("section".into()));
+ }
+ if along == 1.0 {
+ return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into()));
+ }
+ let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?;
+ solve(&s1, &s2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
+ };
+ let esec: Vec> = (0..n)
+ .map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::>>())
+ .collect::>()?;
+ let fits = crate::guard("fit curve blend", || (0..n).map(|k| fit_sections(&esec, k, closed, round)).collect::>>())?;
+ let mut accurate = true;
+ 'edges: for k in 0..n {
+ let fit = fits.get(k).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
+ for j in 0..samples - 1 {
+ for fraction in [0.25, 0.5, 0.75] {
+ let along = (j as f64 + fraction) / (samples - 1) as f64;
+ let section = uniform_section(k, along)?;
+ if !crate::guard("check curve blend fit", || Ok(section_fits(fit, §ion, along, round, sigma, fit_tolerance)))? {
+ accurate = false;
+ break 'edges;
+ }
+ }
+ }
+ }
+ if accurate {
+ break (esec, fits);
+ }
+ if samples >= max_samples {
+ return Err(KernelError::Failed("curve blend: could not fit the rolling-ball surface within the accuracy and section limits".into()));
+ }
+ samples = (2 * samples - 1).min(max_samples);
+ };
crate::guard("curve blend", || {
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
// Vertices and arcs at the chain's vertices.
@@ -334,47 +551,18 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut c2: Vec = Vec::with_capacity(n);
let mut blends: Vec = Vec::with_capacity(n);
for (k, list) in esec.iter().enumerate() {
- let q1: Vec = list.iter().map(|s| s.q1).collect();
- let q2: Vec = list.iter().map(|s| s.q2).collect();
let k1 = (k + 1) % nv;
let (a1, b1, a2, b2) = (v1.get(k), v1.get(k1), v2.get(k), v2.get(k1));
let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) };
- let cv1 = crate::build::interpolate_cubic(&q1).ok_or_else(|| fail("contact curve"))?;
- let cv2 = crate::build::interpolate_cubic(&q2).ok_or_else(|| fail("contact curve"))?;
- let e1 = mt::Edge::new(a1, b1, mt::Curve::BSplineCurve(cv1));
- let e2 = mt::Edge::new(a2, b2, mt::Curve::BSplineCurve(cv2));
- // The blend surface: each control row interpolated along the edge (homogeneous),
- // through two sections of each neighbouring edge too, so that neighbouring blend
- // faces meet smoothly.
- let mut ext: Vec = Vec::with_capacity(list.len() + 4);
- if closed || k > 0 {
- let prev = esec.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
- ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
- }
- ext.extend(list.iter().copied());
- if closed || k + 1 < n {
- let next = esec.get((k + 1) % n).ok_or_else(|| fail("section"))?;
- ext.extend(next.iter().skip(1).take(2).copied());
- }
- let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::>().ok_or_else(|| fail("section"))?;
- let mut rows: Vec> = Vec::with_capacity(3);
- let mut vknots = None;
- for j in 0..3 {
- let xyz: Vec = ctrl.iter().map(|c| Vec3::new(c[j].x, c[j].y, c[j].z)).collect();
- let ww: Vec = ctrl.iter().map(|c| Vec3::new(c[j].w, 0.0, 0.0)).collect();
- let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?;
- let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?;
- vknots = Some(bx.knot_vec().clone());
- rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect());
- }
- let vk = vknots.ok_or_else(|| fail("knots"))?;
- let uk = mt::KnotVec::bezier_knot(2);
- let bs = mt::BSplineSurface::try_new((uk, vk), rows).map_err(|e| fail(&e.to_string()))?;
- let mut surface = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs));
+ let fit = fits.get(k).ok_or_else(|| fail("fit"))?;
+ let [cv1, cv2] = &fit.contacts;
+ let e1 = mt::Edge::new(a1, b1, cv1.clone());
+ let e2 = mt::Edge::new(a2, b2, cv2.clone());
+ let mut surface = fit.surface.clone();
// Outward: toward the ball centre on a concave edge, away on a convex one.
{
use mt::{ParametricSurface3D, SearchNearestParameter};
- let s = list.get(SAMPLES / 2).ok_or_else(|| fail("section"))?;
+ let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?;
let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r;
if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) {
let nn = surface.normal(u, v);
@@ -527,3 +715,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))
})
}
+
+#[cfg(test)]
+#[path = "curveblend_tests.rs"]
+mod tests;
diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs
new file mode 100644
index 00000000..941168a3
--- /dev/null
+++ b/crates/kernel/src/curveblend_tests.rs
@@ -0,0 +1,194 @@
+//! Independent analytic reference: the centre of a ball tangent to two perpendicular
+//! cylinders lies on the intersection of their radius-offset cylinders.
+
+use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
+
+use super::*;
+use crate::{Body, BoolOp, boolean, cylinder, fillet};
+
+fn tee(scale: f64, origin: Vec3) -> Body {
+ let main = cylinder(origin + Vec3::new(-40.0, 0.0, 0.0) * scale, Vec3::X, 15.0 * scale, 80.0 * scale).unwrap();
+ let branch = cylinder(origin, Vec3::Z, 10.0 * scale, 35.0 * scale).unwrap();
+ boolean(&main, &branch, BoolOp::Union).unwrap().unwrap()
+}
+
+fn analytic_section(theta: f64, scale: f64, origin: Vec3) -> (Vec3, Vec3, Vec3) {
+ let centre = Vec3::new(13.0 * theta.cos(), 13.0 * theta.sin(), (324.0 - 169.0 * theta.sin().powi(2)).sqrt());
+ let main = Vec3::new(centre.x, centre.y * 15.0 / 18.0, centre.z * 15.0 / 18.0);
+ let branch = Vec3::new(centre.x * 10.0 / 13.0, centre.y * 10.0 / 13.0, centre.z);
+ (origin + centre * scale, origin + main * scale, origin + branch * scale)
+}
+
+fn nearest_blend(surfaces: &[mt::Surface], point: Vec3) -> (f64, Vec3) {
+ surfaces
+ .iter()
+ .filter_map(|s| {
+ let (u, v) = s.search_nearest_parameter(p3(point), None, 100)?;
+ let actual = from_p3(s.subs(u, v));
+ let n = s.normal(u, v);
+ Some((actual.dist(point), Vec3::new(n.x, n.y, n.z).normalized()?))
+ })
+ .min_by(|a, b| a.0.total_cmp(&b.0))
+ .unwrap()
+}
+
+fn distance_to_analytic_arc(theta: f64, point: Vec3) -> f64 {
+ let (centre, main, branch) = analytic_section(theta, 1.0, Vec3::ZERO);
+ let a = (main - centre).normalized().unwrap();
+ let b = (branch - centre).normalized().unwrap();
+ let plane = a.cross(b).normalized().unwrap();
+ let radial = point - centre;
+ let projected = radial - plane * radial.dot(plane);
+ let direction = projected.normalized().unwrap();
+ let angle = a.dot(b).clamp(-1.0, 1.0).acos();
+ let from_main = a.dot(direction).clamp(-1.0, 1.0).acos();
+ let from_branch = b.dot(direction).clamp(-1.0, 1.0).acos();
+ if (from_main + from_branch - angle).abs() < 1e-7 { point.dist(centre + direction * 3.0) } else { point.dist(main).min(point.dist(branch)) }
+}
+
+fn distance_to_analytic_band(point: Vec3) -> f64 {
+ // The arc's XY azimuth stays between its two contacts' azimuths. Their difference
+ // for this tee is less than 0.1 rad; bracket generously around the query azimuth.
+ let azimuth = point.y.atan2(point.x);
+ let (mut lo, mut hi) = (azimuth - 0.3, azimuth + 0.3);
+ for _ in 0..55 {
+ let a = lo + (hi - lo) * 0.38196601125;
+ let b = lo + (hi - lo) * 0.61803398875;
+ if distance_to_analytic_arc(a, point) < distance_to_analytic_arc(b, point) {
+ hi = b;
+ } else {
+ lo = a;
+ }
+ }
+ distance_to_analytic_arc((lo + hi) * 0.5, point)
+}
+
+#[test]
+fn fitted_contact_edges_coincide_with_their_surface_boundaries() {
+ let sections: Vec> = (0..4)
+ .map(|k| {
+ (0..SAMPLES)
+ .map(|i| {
+ let theta = std::f64::consts::FRAC_PI_2 * (k as f64 + i as f64 / (SAMPLES - 1) as f64);
+ let (c, q1, q2) = analytic_section(theta, 1.0, Vec3::ZERO);
+ Section { c, q1, q2 }
+ })
+ .collect()
+ })
+ .collect();
+ for k in 0..sections.len() {
+ let fit = crate::guard("contact consistency", || fit_sections(§ions, k, true, true)).unwrap();
+ for (side, curve) in fit.contacts.iter().enumerate() {
+ let (lo, hi) = curve.range_tuple();
+ for i in 0..=200 {
+ let parameter = lo + (hi - lo) * i as f64 / 200.0;
+ let edge = from_p3(curve.subs(parameter));
+ let surface = from_p3(fit.surface.subs(side as f64, parameter));
+ assert!(edge.dist(surface) < 1e-9, "contact-to-surface gap {}", edge.dist(surface));
+ }
+ }
+ }
+}
+
+#[test]
+fn rolling_ball_matches_analytic_tee_and_contacts() {
+ for (scale, origin) in [(1.0, Vec3::ZERO), (0.25, Vec3::new(23.0, -17.0, 9.0))] {
+ let body = tee(scale, origin);
+ let point = origin + Vec3::new(0.0, 10.0, 125f64.sqrt()) * scale;
+ let blend = fillet(&body, &[point], 3.0 * scale).unwrap();
+ assert!(blend.validity().is_empty(), "{:?}", blend.validity());
+ let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
+ assert!(!surfaces.is_empty());
+ let mut distance: f64 = 0.0;
+ let mut tangent_angle: f64 = 0.0;
+ for i in 0..96 {
+ // Offset the angular samples from patch seams; contact endpoints are tested below.
+ let theta = std::f64::consts::TAU * (i as f64 + 0.37) / 96.0;
+ let (centre, main, branch) = analytic_section(theta, scale, origin);
+ let a = (main - centre).normalized().unwrap();
+ let b = (branch - centre).normalized().unwrap();
+ let angle = a.dot(b).clamp(-1.0, 1.0).acos();
+ for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
+ // Spherical linear interpolation of the radial unit vectors, independent of
+ // the production rational control-point construction and fitting parameters.
+ let radial = (a * ((1.0 - fraction) * angle).sin() + b * (fraction * angle).sin()) / angle.sin();
+ let exact = centre + radial * (3.0 * scale);
+ let (error, normal) = nearest_blend(&surfaces, exact);
+ distance = distance.max(error);
+ if fraction == 0.0 || fraction == 1.0 {
+ let want = (centre - exact).normalized().unwrap();
+ tangent_angle = tangent_angle.max(normal.dot(want).abs().min(1.0).acos().to_degrees());
+ }
+ }
+ }
+ assert!(distance <= 0.005, "scale {scale}: analytic surface deviation {distance} mm");
+ assert!(tangent_angle <= 0.5, "scale {scale}: contact normals differ by {tangent_angle} degrees");
+
+ // The reverse direction uses points on the actual trimmed face mesh, so an
+ // untrimmed neighbouring surface cannot conceal an overshooting blend patch.
+ let mesh = blend.tessellate_with(0.002, true).unwrap();
+ let mut reverse_distance: f64 = 0.0;
+ let mut probes = 0;
+ for point in mesh.positions.iter().step_by(19) {
+ let p = (*point - origin) / scale;
+ if (p.x * p.x + p.y * p.y).sqrt() <= 10.000001
+ || (p.y * p.y + p.z * p.z).sqrt() <= 15.000001
+ || !(10.0..20.0).contains(&p.z)
+ || p.x.abs() > 16.0
+ {
+ continue;
+ }
+ probes += 1;
+ reverse_distance = reverse_distance.max(distance_to_analytic_band(p) * scale);
+ }
+ assert!(probes > 300, "only {probes} trimmed surface points checked");
+ assert!(reverse_distance <= 0.005, "scale {scale}: reverse analytic deviation {reverse_distance} mm");
+ eprintln!("scale {scale}: surface {distance:.9} mm, reverse {reverse_distance:.9} mm, contact normals {tangent_angle:.6} degrees");
+
+ let mut contact_count = 0;
+ for edge in Body::unique_edges(&blend.solid) {
+ let curve = edge.oriented_curve();
+ let (lo, hi) = curve.range_tuple();
+ let endpoints = [from_p3(curve.subs(lo)), from_p3(curve.subs(hi))].map(|p| (p - origin) / scale);
+ if endpoints.iter().any(|p| !(9.0..20.0).contains(&p.z) || p.x.abs() > 16.0) {
+ continue;
+ }
+ let branch = endpoints.iter().all(|p| ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 1e-5);
+ let main = endpoints.iter().all(|p| ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 1e-5);
+ if !branch && !main {
+ continue;
+ }
+ contact_count += 1;
+ for i in 0..=200 {
+ let p = (from_p3(curve.subs(lo + (hi - lo) * i as f64 / 200.0)) - origin) / scale;
+ let radius_error = if branch { ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() } else { ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() };
+ assert!(radius_error * scale <= 0.002, "supporting cylinder deviation {} mm", radius_error * scale);
+ }
+ }
+ assert!(contact_count >= 4, "only {contact_count} contacts checked");
+ }
+}
+
+#[test]
+fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() {
+ let body = tee(1.0, Vec3::ZERO);
+ let solid = body.deep_copy();
+ let ids: Vec<_> = Body::unique_edges(&solid)
+ .iter()
+ .filter(|edge| {
+ let curve = edge.oriented_curve();
+ let (lo, hi) = curve.range_tuple();
+ let p = from_p3(curve.subs((lo + hi) * 0.5));
+ p.z > 10.0 && ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 0.01 && ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 0.01
+ })
+ .map(mt::Edge::id)
+ .collect();
+ assert!(!ids.is_empty());
+ let before = crate::step_export(&[&body], "unchanged").unwrap();
+ let input_before = crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap();
+ // Four sections deliberately undersample the saddle; production starts at 24.
+ let error = crate::guard("limited rolling ball", || curve_blend_with_limit(&solid, &ids, body.size(), 3.0, true, 4)).unwrap_err();
+ assert!(error.to_string().contains("accuracy and section limits"), "{error}");
+ assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before);
+ assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before);
+}
diff --git a/crates/kernel/src/step_in/geom.rs b/crates/kernel/src/step_in/geom.rs
index af767dfb..52e4d8f0 100644
--- a/crates/kernel/src/step_in/geom.rs
+++ b/crates/kernel/src/step_in/geom.rs
@@ -1,7 +1,9 @@
//! STEP geometry (ISO 10303-42 entities) to kernel curves and surfaces.
//!
//! Points, placements, lines, conics, B-splines (plain and rational), elementary surfaces and
-//! swept surfaces. Edge curves are trimmed to their vertices here, so every kernel edge runs
+//! swept surfaces, with exact offsets of planes, cylinders, cones, spheres and regular tori.
+//! Free-form offsets and offsets through collapsed radii remain explicit import warnings.
+//! Edge curves are trimmed to their vertices here, so every kernel edge runs
//! exactly from its start vertex to its end vertex. Lengths are scaled to millimetres while
//! reading.
@@ -761,6 +763,59 @@ pub(crate) fn bspline_surface(cx: &Ctx, e: &Entity) -> R {
}
}
+/// Resolve exact analytic offsets. Each intermediate offset is checked: an invalid inner
+/// offset must not become valid merely because an outer offset cancels its distance.
+/// Entity attributes follow ISO 10303-42 (`basis_surface`, `distance`, `self_intersect`).
+fn offset_basis(cx: &Ctx, id: u64, depth: usize) -> R<(u64, f64)> {
+ if depth > MAX_GEOM_DEPTH {
+ return Err("surface definitions nested too deeply".into());
+ }
+ let e = entity(cx, id)?;
+ if e.name() != "OFFSET_SURFACE" {
+ return Ok((id, 0.0));
+ }
+ let p = e.params();
+ let basis = p.get(1).and_then(Param::as_ref_id).ok_or("offset surface without basis")?;
+ let distance = length(cx, p.get(2), "offset distance")?;
+ if !matches!(p.get(3).and_then(Param::as_enum), Some("T" | "F" | "U")) {
+ return Err("offset surface without a logical self_intersect flag".into());
+ }
+ let (basis, inner) = offset_basis(cx, basis, depth + 1)?;
+ let offset = inner + distance;
+ if !offset.is_finite() || offset.abs() >= MAX_COORD {
+ return Err("offset distance out of range after unit conversion".into());
+ }
+ let e = entity(cx, basis)?;
+ let p = e.params();
+ match e.name() {
+ "PLANE" => {}
+ "CYLINDRICAL_SURFACE" | "SPHERICAL_SURFACE" => {
+ let radius = length(cx, p.get(2), "radius")?;
+ if radius <= 0.0 || !(1e-9..MAX_COORD).contains(&(radius + offset)) {
+ return Err("offset surface collapses or reverses its radius".into());
+ }
+ }
+ "CONICAL_SURFACE" => {
+ let radius = length(cx, p.get(2), "radius")?;
+ let angle = num(p.get(3), "semi angle")? * cx.ang;
+ if radius < 0.0 || !(angle.abs() > 1e-9 && angle.abs() < FRAC_PI_2 - 1e-9) || !(0.0..MAX_COORD).contains(&(radius + offset / angle.cos()))
+ {
+ return Err("offset cone has an invalid reference radius or angle".into());
+ }
+ }
+ "TOROIDAL_SURFACE" => {
+ let major = length(cx, p.get(2), "major radius")?;
+ let minor = length(cx, p.get(3), "minor radius")?;
+ if minor <= 0.0 || major <= minor || major >= MAX_COORD || !(1e-9..major).contains(&(minor + offset)) {
+ return Err("offset torus collapses or becomes self-intersecting".into());
+ }
+ }
+ _ if offset == 0.0 => {}
+ n => return Err(format!("unsupported OFFSET_SURFACE basis surface {n}")),
+ }
+ Ok((basis, offset))
+}
+
/// A STEP surface entity as a kernel surface whose normal is the STEP surface normal.
/// `extent` holds points of the face boundary (bounds the analytic surfaces' parameter ranges).
/// `sense` false gives the opposite normal (a face with `same_sense = .F.`).
@@ -776,6 +831,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
if depth > MAX_GEOM_DEPTH {
return Err("surface definitions nested too deeply".into());
}
+ let (id, offset) = offset_basis(cx, id, depth)?;
let e = entity(cx, id)?;
if e.is_complex() {
return bspline_surface(cx, e).map(flip);
@@ -785,11 +841,12 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
match e.name() {
"PLANE" => {
let f = placement()?;
- Ok(flip(mt::Surface::Plane(mt::Plane::new(f.o, f.o + f.x, f.o + f.y))))
+ let o = f.o + f.z * offset;
+ Ok(flip(mt::Surface::Plane(mt::Plane::new(o, o + f.x, o + f.y))))
}
"CYLINDRICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
- let r = length(cx, p.get(2), "radius")?;
+ let r = length(cx, p.get(2), "radius")? + offset;
if r <= 0.0 {
return Err("cylinder radius must be positive".into());
}
@@ -799,8 +856,9 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"CONICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
- let r = length(cx, p.get(2), "radius")?;
+ let basis_r = length(cx, p.get(2), "radius")?;
let a = num(p.get(3), "semi angle")? * cx.ang;
+ let r = basis_r + offset / a.cos();
if r < 0.0 || !(a.abs() > 1e-9 && a.abs() < FRAC_PI_2 - 1e-9) {
return Err("bad cone".into());
}
@@ -821,7 +879,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"SPHERICAL_SURFACE" => {
let f = placement()?;
- let r = length(cx, p.get(2), "radius")?;
+ let r = length(cx, p.get(2), "radius")? + offset;
if r <= 0.0 {
return Err("sphere radius must be positive".into());
}
@@ -844,7 +902,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"TOROIDAL_SURFACE" => {
let f = placement()?;
- let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")?);
+ let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")? + offset);
if small <= 0.0 || big < 0.0 {
return Err("bad torus radii".into());
}
diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs
index cb8e9f2c..a8bcd8fc 100644
--- a/crates/kernel/src/step_in/tests.rs
+++ b/crates/kernel/src/step_in/tests.rs
@@ -76,6 +76,106 @@ fn imported_bodies_take_features() {
const HEAD: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('t','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n";
const TAIL: &str = "ENDSEC;\nEND-ISO-10303-21;\n";
+/// Original fixtures: elementary basis surfaces plus offsets, with no external CAD files.
+fn offset_geometry(definition: &str, offset: &str, len: f64, sense: bool, points: &[mt::Point3]) -> crate::Result {
+ let text = format!(
+ "{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=AXIS2_PLACEMENT_3D('',#1,$,$);\n#3={definition};\n#4=OFFSET_SURFACE('',#3,{offset},.U.);\n{TAIL}"
+ );
+ let ex = p21::parse(&text).unwrap();
+ let cx = Ctx { ex: &ex, len, ang: 1.0, tol: 1e-6 };
+ crate::guard("test offset surface", || geom::surface(&cx, 4, points, &[], sense, 0).map_err(KernelError::Failed))
+}
+
+#[test]
+fn offset_surface_analytic_geometry_and_orientation() {
+ use mt::{InnerSpace, MetricSpace, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
+ let angle: f64 = 0.4;
+ let cases = [
+ ("PLANE('',#2)".to_string(), mt::Point3::new(2.0, 3.0, 1.0), mt::Vector3::unit_z()),
+ ("CYLINDRICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 2.0), mt::Vector3::unit_x()),
+ ("SPHERICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 0.0), mt::Vector3::unit_x()),
+ ("TOROIDAL_SURFACE('',#2,10.,2.)".to_string(), mt::Point3::new(13.0, 0.0, 0.0), mt::Vector3::unit_x()),
+ (
+ format!("CONICAL_SURFACE('',#2,5.,{angle})"),
+ mt::Point3::new(5.0 + 1.0 / angle.cos() + 2.0 * angle.tan(), 0.0, 2.0),
+ mt::Vector3::new(angle.cos(), 0.0, -angle.sin()),
+ ),
+ ];
+ for (definition, point, normal) in cases {
+ for sense in [true, false] {
+ let extent = [point, point + mt::Vector3::unit_z()];
+ let surface = offset_geometry(&definition, "1.", 1.0, sense, &extent).unwrap();
+ let uv = surface.search_nearest_parameter(point, None, 100).unwrap();
+ assert!(surface.subs(uv.0, uv.1).distance(point) < 1e-6, "{definition}");
+ assert!(surface.normal(uv.0, uv.1).dot(normal) * if sense { 1.0 } else { -1.0 } > 0.99, "{definition}, sense {sense}");
+ }
+ }
+ let p = mt::Point3::new(40.0, 0.0, 2.0);
+ let surface = offset_geometry("CYLINDRICAL_SURFACE('',#2,5.)", "-1.", 10.0, true, &[p, p + mt::Vector3::unit_z()]).unwrap();
+ let uv = surface.search_nearest_parameter(p, None, 100).unwrap();
+ assert!(surface.subs(uv.0, uv.1).distance(p) < 1e-6);
+}
+
+#[test]
+fn offset_surfaces_nested_and_hostile() {
+ let points = [mt::Point3::new(0.0, 0.0, 0.0), mt::Point3::new(0.0, 0.0, 1.0)];
+ for (basis, offset) in [
+ ("CYLINDRICAL_SURFACE('',#2,5.)", "-5."),
+ ("SPHERICAL_SURFACE('',#2,5.)", "-6."),
+ ("TOROIDAL_SURFACE('',#2,10.,2.)", "8."),
+ ("TOROIDAL_SURFACE('',#2,10.,2.)", "-2."),
+ ("CONICAL_SURFACE('',#2,5.,0.4)", "-10."),
+ ("PLANE('',#2)", "1.E100"),
+ ("PLANE('',#2)", "$"),
+ ] {
+ assert!(offset_geometry(basis, offset, 1.0, true, &points).is_err(), "{basis}, {offset}");
+ }
+ let text = format!("{HEAD}#1=OFFSET_SURFACE('',#1,1.,.F.);{TAIL}");
+ let ex = p21::parse(&text).unwrap();
+ let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
+ assert!(geom::surface(&cx, 1, &points, &[], true, 0).unwrap_err().contains("nested too deeply"));
+ let text = format!(
+ "{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));#2=AXIS2_PLACEMENT_3D('',#1,$,$);#3=CYLINDRICAL_SURFACE('',#2,5.);#4=OFFSET_SURFACE('',#3,-6.,.F.);#5=OFFSET_SURFACE('',#4,6.,.F.);{TAIL}"
+ );
+ let ex = p21::parse(&text).unwrap();
+ let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
+ assert!(geom::surface(&cx, 5, &points, &[], true, 0).is_err());
+}
+
+#[test]
+fn offset_surface_cube_import_is_closed_and_editable() {
+ // Move each basis plane half a file unit inward; nested quarter-unit offsets
+ // bring its supporting surface back to the original unit cube boundary.
+ let mut entities = cube_entities();
+ let origins = [(0.0, 0.0, 0.5), (0.0, 0.0, 0.5), (0.0, 0.5, 0.0), (0.5, 0.0, 0.0), (1.0, 0.5, 0.0), (0.5, 1.0, 0.0)];
+ for (f, (x, y, z)) in origins.into_iter().enumerate() {
+ let base = 400 + f * 20;
+ let extra = 1000 + f * 10;
+ entities = entities
+ .replace(&format!("#{}=PLANE('',#{});", base + 10, base + 9), &format!("#{}=OFFSET_SURFACE('',#{},0.25,.F.);", base + 10, extra + 3));
+ entities += &format!(
+ "#{extra}=CARTESIAN_POINT('',({x},{y},{z}));\n#{}=AXIS2_PLACEMENT_3D('',#{extra},#{},$);\n#{}=PLANE('',#{});\n#{}=OFFSET_SURFACE('',#{},0.25,.F.);\n",
+ extra + 1,
+ base + 8,
+ extra + 2,
+ extra + 1,
+ extra + 3,
+ extra + 2
+ );
+ }
+ entities += &context(10, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))");
+ entities += "#9=ADVANCED_BREP_SHAPE_REPRESENTATION('Offsets',(#101),#10);\n";
+ let import = step_import(&format!("{HEAD}{entities}{TAIL}")).unwrap();
+ assert!(import.warnings.is_empty(), "{:?}", import.warnings);
+ assert!(import.bodies[0].closed);
+ assert_eq!(import.bodies[0].file_faces, 6);
+ let body = &import.bodies[0].body;
+ assert!(rel(measure(body).unwrap().volume, 1000.0) < 1e-6);
+ let cut = cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap();
+ let result = boolean(body, &cut, BoolOp::Cut).unwrap().unwrap();
+ assert!(rel(measure(&result).unwrap().volume, 1000.0 - 40.0 * std::f64::consts::PI) < 1e-3);
+}
+
/// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items.
fn cube_entities() -> String {
let mut s = String::new();
diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs
index c02ce3da..088b7b54 100644
--- a/crates/sketch/src/link.rs
+++ b/crates/sketch/src/link.rs
@@ -300,6 +300,13 @@ fn same_geometry(a: &[(LCurve, Vec)], b: &[(LCurve, Vec)]) -> bool {
if a.len() != b.len() {
return false;
}
+ // Unchanged ordered links are common; avoid a quadratic permutation scan.
+ if a.iter().zip(b).all(|(x, y)| matches(x, y)) {
+ return true;
+ }
+ if a.len() > 512 {
+ return false;
+ }
let mut used = vec![false; b.len()];
a.iter().all(|x| {
let hit = b.iter().enumerate().find(|(j, y)| !used.get(*j).copied().unwrap_or(true) && matches(x, y)).map(|(j, _)| j);
@@ -330,6 +337,48 @@ fn curve_points(c: &LCurve) -> Vec {
}
}
+/// Incidence roles distinguish nearby arc centres, endpoints and hole centres during a
+/// resize. Spatial proximity alone can select a different kind of point in a dense rim.
+fn point_roles(curves: &[LCurve], point_count: usize) -> Vec<[usize; 10]> {
+ let mut roles = vec![[0; 10]; point_count];
+ let mut add = |point: usize, role: usize| {
+ if let Some(count) = roles.get_mut(point).and_then(|r| r.get_mut(role)) {
+ *count += 1;
+ }
+ };
+ for curve in curves {
+ match *curve {
+ LCurve::Line(a, b) => {
+ add(a, 0);
+ add(b, 0);
+ }
+ LCurve::Circle(c, _) => add(c, 1),
+ LCurve::Arc(c, a, b) => {
+ add(c, 2);
+ add(a, 3);
+ add(b, 3);
+ }
+ LCurve::Conic(a, b, x, _) => {
+ add(a, 4);
+ add(b, 4);
+ add(x, 5);
+ }
+ LCurve::Ellipse(c, m, _) => {
+ add(c, 6);
+ add(m, 7);
+ }
+ LCurve::Spline(a, b, first, count, ..) => {
+ add(a, 8);
+ add(b, 8);
+ for i in first..first + count {
+ add(i, 9);
+ }
+ }
+ }
+ }
+ roles
+}
+
impl Sketch {
pub fn link(&self, id: &str) -> Option<&Link> {
self.links.iter().find(|l| l.id == id)
@@ -539,26 +588,6 @@ impl Sketch {
if let Some(l) = self.links.iter_mut().find(|l| l.id == id) {
l.lost = false;
}
- if same {
- for (k, pi) in points.iter().enumerate() {
- let p = order.get(k).and_then(|li| lay.pts.get(*li)).copied();
- if let (Some(p), Some(sp)) = (p, self.points.get_mut(*pi)) {
- sp.pos = p;
- }
- }
- for (ci, c) in curves.iter().zip(&new) {
- if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
- *slot = *r;
- }
- if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
- *rho = *r;
- }
- if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
- *slot = *r;
- }
- }
- return Ok(false);
- }
// The same geometry walked in another order or direction (e.g. a face loop re-read after
// a rollback) is not a change: keep the entities, and the constraints on them.
if let Some(cur) = &cur {
@@ -588,6 +617,7 @@ impl Sketch {
&& points.len() == lay.pts.len()
&& cur.len() == lay.curves.len()
&& points.len() <= 512
+ && cur.len() <= 512
{
let old: Vec = points.iter().filter_map(|i| self.point(*i)).collect();
let normalized = |ps: &[Vec2]| -> Vec {
@@ -596,9 +626,11 @@ impl Sketch {
ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect()
};
let (a, b) = (normalized(&old), normalized(&lay.pts));
+ let (old_roles, fresh_roles) = (point_roles(cur, old.len()), point_roles(&lay.curves, lay.pts.len()));
let mut mapping = Vec::new();
- for p in &a {
- let mut candidates: Vec<(usize, f64)> = b.iter().enumerate().map(|(i, q)| (i, p.dist(*q))).collect();
+ for (old_i, p) in a.iter().enumerate() {
+ let mut candidates: Vec<(usize, f64)> =
+ b.iter().enumerate().filter(|(i, _)| old_roles.get(old_i) == fresh_roles.get(*i)).map(|(i, q)| (i, p.dist(*q))).collect();
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
if let Some((i, d)) = candidates.first()
&& *d < 0.1
@@ -613,22 +645,59 @@ impl Sketch {
let mut used = Vec::new();
let mut curve_map = Vec::new();
if mapping.len() == points.len() {
- for c in cur {
+ for (curve_i, c) in cur.iter().enumerate() {
let mapped: Option> = curve_points(c).iter().map(|i| mapping.get(*i).copied()).collect();
- let found = lay
+ let candidates: Vec = lay
.curves
.iter()
.enumerate()
- .find(|(i, fresh)| {
- if used.contains(i) || !same_shape(c, fresh) {
- return false;
+ .filter_map(|(i, fresh)| {
+ if !same_shape(c, fresh) {
+ return None;
}
let fresh_pts = curve_points(fresh);
- mapped.as_ref().is_some_and(|p| {
- *p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::>() == fresh_pts
- })
+ mapped
+ .as_ref()
+ .is_some_and(|p| {
+ *p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::>() == fresh_pts
+ })
+ .then_some(i)
})
- .map(|(i, _)| i);
+ .collect();
+ let found = if candidates.len() == 1 {
+ candidates.first().copied()
+ } else if let LCurve::Circle(center, _) = c {
+ // Concentric circular boundaries share a point graph. Their radius
+ // order identifies the inner/outer rings through an upstream resize.
+ let mut previous: Vec<(usize, f64)> = cur
+ .iter()
+ .enumerate()
+ .filter_map(|(i, x)| match x {
+ LCurve::Circle(p, r) if p == center => Some((i, *r)),
+ _ => None,
+ })
+ .collect();
+ let mut incoming: Vec<(usize, f64)> = candidates
+ .iter()
+ .filter_map(|i| match lay.curves.get(*i) {
+ Some(LCurve::Circle(_, r)) => Some((*i, *r)),
+ _ => None,
+ })
+ .collect();
+ previous.sort_by(|a, b| a.1.total_cmp(&b.1));
+ incoming.sort_by(|a, b| a.1.total_cmp(&b.1));
+ let distinct = |xs: &[(usize, f64)]| {
+ xs.windows(2).all(|w| matches!(w, [a, b] if b.1 - a.1 > MERGE_TOL * (1.0 + a.1.abs().max(b.1.abs()))))
+ };
+ if previous.len() == incoming.len() && distinct(&previous) && distinct(&incoming) {
+ previous.iter().position(|(i, _)| *i == curve_i).and_then(|rank| incoming.get(rank).map(|(i, _)| *i))
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+ let found = found.filter(|i| !used.contains(i));
if let Some(i) = found {
used.push(i);
curve_map.push(i);
@@ -649,10 +718,47 @@ impl Sketch {
{
*r = *value;
}
+ if let (Some(Curve { kind: CurveKind::Conic { rho, .. }, .. }), Some(LCurve::Conic(.., value))) =
+ (self.curves.get_mut(*ci), lay.curves.get(li))
+ {
+ *rho = *value;
+ }
+ if let (Some(Curve { kind: CurveKind::Ellipse { r, .. }, .. }), Some(LCurve::Ellipse(.., value))) =
+ (self.curves.get_mut(*ci), lay.curves.get(li))
+ {
+ *r = *value;
+ }
}
return Ok(false);
}
}
+ // Equal topology alone does not identify a face boundary: an outer rim and a
+ // hole can have identical point graphs. Never move saved curve ids between them
+ // when the geometric match is ambiguous; the document reports a lost link.
+ if same && self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) {
+ self.set_link_lost(id, true);
+ return Err(SketchError::Invalid("projected face boundary identity is ambiguous; last geometry retained".into()));
+ }
+ if same {
+ for (k, pi) in points.iter().enumerate() {
+ let p = order.get(k).and_then(|li| lay.pts.get(*li)).copied();
+ if let (Some(p), Some(sp)) = (p, self.points.get_mut(*pi)) {
+ sp.pos = p;
+ }
+ }
+ for (ci, c) in curves.iter().zip(&new) {
+ if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
+ *slot = *r;
+ }
+ if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
+ *rho = *r;
+ }
+ if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
+ *slot = *r;
+ }
+ }
+ return Ok(false);
+ }
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
self.drop_link_entities(id);
self.build_link(id, &lay)?;
@@ -724,3 +830,7 @@ impl Sketch {
self.links.retain(|_| k.next().copied().unwrap_or(true));
}
}
+
+#[cfg(test)]
+#[path = "link_identity_tests.rs"]
+mod identity_tests;
diff --git a/crates/sketch/src/link_identity_tests.rs b/crates/sketch/src/link_identity_tests.rs
new file mode 100644
index 00000000..d9c202b1
--- /dev/null
+++ b/crates/sketch/src/link_identity_tests.rs
@@ -0,0 +1,158 @@
+use super::*;
+use crate::{ConstraintKind, solve};
+
+fn square(size: f64) -> Vec {
+ let pts = [Vec2::new(-size, -size), Vec2::new(size, -size), Vec2::new(size, size), Vec2::new(-size, size)];
+ (0..4).map(|i| LinkGeom::Line(pts[i], pts[(i + 1) % 4])).collect()
+}
+
+fn face() -> LinkSource {
+ LinkSource::Face { body: "Case".into(), at: Vec3::ZERO }
+}
+
+fn ring(outer_first: bool, outer: f64, inner: f64) -> Vec {
+ let (first, second) = if outer_first { (outer, inner) } else { (inner, outer) };
+ square(first).into_iter().chain(square(second)).collect()
+}
+
+#[test]
+fn saved_face_link_keeps_inner_ids_when_boundary_order_changes() {
+ let mut sk = Sketch::new();
+ // An older saved face may have enumerated its inner boundary first.
+ let link = sk.add_link(LinkKind::Project, face(), &ring(false, 10.0, 8.0)).unwrap();
+ let inner = sk.link_curves(&link)[0];
+ let corner = match sk.curves[inner].kind {
+ CurveKind::Line { a, .. } => a,
+ _ => panic!(),
+ };
+ let free = sk.add_point(Vec2::new(-8.0, -8.0), None).unwrap();
+ sk.add_constraint(ConstraintKind::Coincident { p: free, q: corner }, None).unwrap();
+ let serialized = serde_json::to_string(&sk).unwrap();
+ let mut sk: Sketch = serde_json::from_str(&serialized).unwrap();
+ let before = sk.clone();
+ assert!(!sk.update_link(&link, &ring(true, 10.0, 8.0)).unwrap());
+ assert_eq!(sk, before, "canonical outer-first enumeration must not reinterpret saved ids");
+ assert!(solve(&mut sk).ok());
+ assert_eq!(sk.points[free].pos, Vec2::new(-8.0, -8.0));
+}
+
+#[test]
+fn resized_and_reordered_face_keeps_constraints_on_the_same_boundary() {
+ let mut sk = Sketch::new();
+ let link = sk.add_link(LinkKind::Project, face(), &ring(false, 10.0, 8.0)).unwrap();
+ let points = sk.link_points(&link);
+ let original: Vec = points.iter().map(|p| sk.points[*p].pos).collect();
+ let ids: Vec = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect();
+ let free = sk.add_point(Vec2::new(-8.0, -8.0), None).unwrap();
+ sk.add_constraint(ConstraintKind::Coincident { p: free, q: points[0] }, None).unwrap();
+ let fresh: Vec = ring(true, 10.0, 8.0)
+ .into_iter()
+ .map(|g| match g {
+ LinkGeom::Line(a, b) => LinkGeom::Line(Vec2::new(b.x * 2.0, b.y * 3.0), Vec2::new(a.x * 2.0, a.y * 3.0)),
+ _ => panic!(),
+ })
+ .collect();
+ assert!(!sk.update_link(&link, &fresh).unwrap());
+ for (p, old) in points.iter().zip(original) {
+ assert_eq!(sk.points[*p].pos, Vec2::new(old.x * 2.0, old.y * 3.0));
+ }
+ assert_eq!(ids, sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect::>());
+ assert!(solve(&mut sk).ok());
+ assert!(sk.points[free].pos.dist(Vec2::new(-16.0, -24.0)) < 1e-7);
+}
+
+#[test]
+fn ambiguous_face_refresh_preserves_last_geometry_and_marks_link_lost() {
+ let mut sk = Sketch::new();
+ let link = sk.add_link(LinkKind::Project, face(), &ring(true, 10.0, 9.9)).unwrap();
+ let before = sk.clone();
+ assert!(sk.update_link(&link, &ring(false, 10.0, 2.0)).is_err());
+ assert_eq!(sk.points, before.points);
+ assert_eq!(sk.curves, before.curves);
+ assert!(sk.link(&link).unwrap().lost);
+}
+
+#[test]
+fn large_face_permutation_is_bounded_and_preserves_geometry() {
+ let mut sk = Sketch::new();
+ let geom: Vec = (1..=129).flat_map(|i| square(i as f64)).collect();
+ let link = sk.add_link(LinkKind::Project, face(), &geom).unwrap();
+ assert!(!sk.update_link(&link, &geom).unwrap(), "ordered unchanged geometry needs no permutation scan");
+ let before = sk.clone();
+ let reversed: Vec = (1..=129).rev().flat_map(|i| square(i as f64)).collect();
+ assert!(sk.update_link(&link, &reversed).is_err());
+ assert_eq!(sk.points, before.points);
+ assert_eq!(sk.curves, before.curves);
+}
+
+#[test]
+fn geometric_refresh_updates_conic_and_ellipse_scalar_values() {
+ let mut sk = Sketch::new();
+ let geom = |rho, minor| {
+ vec![
+ LinkGeom::Conic { a: Vec2::new(1.0, 1.0), apex: Vec2::new(2.0, 3.0), b: Vec2::new(4.0, 1.0), rho },
+ LinkGeom::Ellipse { c: Vec2::new(10.0, 10.0), major: Vec2::new(14.0, 10.0), minor },
+ ]
+ };
+ let link = sk.add_link(LinkKind::Project, LinkSource::Origin, &geom(0.4, 2.0)).unwrap();
+ assert!(!sk.update_link(&link, &geom(0.6, 3.0)).unwrap());
+ assert!(matches!(sk.curves[0].kind, CurveKind::Conic { rho, .. } if rho == 0.6));
+ assert!(matches!(sk.curves[1].kind, CurveKind::Ellipse { r, .. } if r == 3.0));
+}
+
+#[test]
+fn many_curves_sharing_few_points_do_not_enter_permutation_matching() {
+ let mut sk = Sketch::new();
+ let geom = vec![LinkGeom::Line(Vec2::new(1.0, 1.0), Vec2::new(2.0, 2.0)); 513];
+ let link = sk.add_link(LinkKind::Project, face(), &geom).unwrap();
+ assert_eq!(sk.link_points(&link).len(), 2);
+ let before = sk.clone();
+ let shifted = vec![LinkGeom::Line(Vec2::new(3.0, 3.0), Vec2::new(4.0, 4.0)); 513];
+ assert!(sk.update_link(&link, &shifted).is_err());
+ assert_eq!(sk.points, before.points);
+ assert_eq!(sk.curves, before.curves);
+ assert!(sk.link(&link).unwrap().lost);
+}
+
+#[test]
+fn concentric_face_resize_keeps_inner_outer_circle_identity() {
+ let mut sk = Sketch::new();
+ let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 8.0), LinkGeom::Circle(Vec2::ZERO, 10.0)]).unwrap();
+ let ids: Vec = sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect();
+ assert!(!sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 20.0), LinkGeom::Circle(Vec2::ZERO, 16.0)]).unwrap());
+ assert!(matches!(sk.curves[0].kind, CurveKind::Circle { r, .. } if r == 16.0));
+ assert!(matches!(sk.curves[1].kind, CurveKind::Circle { r, .. } if r == 20.0));
+ assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::>());
+ // Coincident circles do not have a unique boundary identity.
+ let before = sk.clone();
+ assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 18.0), LinkGeom::Circle(Vec2::ZERO, 18.0)]).is_err());
+ assert_eq!(sk.points, before.points);
+ assert_eq!(sk.curves, before.curves);
+}
+
+#[test]
+fn resized_dense_rim_matches_arc_centres_by_role_before_proximity() {
+ let mut sk = Sketch::new();
+ let geometry = |half_width: f64, x: f64| {
+ let mut out = square(half_width);
+ let c = Vec2::new(x, -16.5);
+ let left = c - Vec2::X * 3.0;
+ let right = c + Vec2::X * 3.0;
+ out.push(LinkGeom::Arc { c, a: right, b: left });
+ out.push(LinkGeom::Arc { c, a: left, b: right });
+ out
+ };
+ let link = sk.add_link(LinkKind::Project, face(), &geometry(38.0, -26.5)).unwrap();
+ let arcs: Vec = sk.link_curves(&link).into_iter().filter(|i| matches!(sk.curves[*i].kind, CurveKind::Arc { .. })).collect();
+ let ids: Vec = arcs.iter().map(|i| sk.curves[*i].id.clone()).collect();
+ // The old centre is spatially closer in normalized space to a fresh endpoint.
+ // Both semicircles must still keep the same shared centre and logical endpoints.
+ assert!(!sk.update_link(&link, &geometry(43.0, -31.5)).unwrap());
+ for i in &arcs {
+ let CurveKind::Arc { c, a, b } = sk.curves[*i].kind else { panic!() };
+ assert_eq!(sk.points[c].pos, Vec2::new(-31.5, -16.5));
+ assert!((sk.points[a].pos.dist(sk.points[c].pos) - 3.0).abs() < 1e-9);
+ assert!((sk.points[b].pos.dist(sk.points[c].pos) - 3.0).abs() < 1e-9);
+ }
+ assert_eq!(ids, arcs.iter().map(|i| sk.curves[*i].id.clone()).collect::>());
+}
diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs
index c703e2e6..b1779f56 100644
--- a/crates/sketch/src/solver.rs
+++ b/crates/sketch/src/solver.rs
@@ -9,7 +9,7 @@
//! gives the remaining degrees of freedom and which points are fully determined.
use serde::{Deserialize, Serialize};
-use solvecraft_geom::Vec2;
+use solvecraft_geom::{Seg2, Vec2};
use crate::linalg::{Mat, determined_vars, solve as lin_solve};
use crate::model::{ConstraintKind, CurveKind, Sketch};
@@ -177,6 +177,51 @@ impl State<'_> {
_ => Vec::new(),
}
}
+ /// Point-on-curve constraints use the exact shape. A polyline residual would move an
+ /// exact crossing onto its chord, changing geometry merely by splitting it.
+ fn freeform_dist(&self, c: usize, q: Vec2) -> f64 {
+ let segs: Vec = match self.sk.curves.get(c).map(|c| &c.kind) {
+ Some(CurveKind::Ellipse { c: ci, m, .. }) => {
+ let center = self.p(*ci);
+ let u = self.p(*m) - center;
+ let a = u.len();
+ let r = self.radius_of(c);
+ if a <= 1e-12 || r <= 1e-12 {
+ return q.dist(center);
+ }
+ let delta = q - center;
+ let x = delta.dot(u) / (a * a);
+ let y = delta.cross(u) / (a * r);
+ return (x.hypot(y) - 1.0) * a.min(r);
+ }
+ Some(CurveKind::Spline { pts, control, degree }) => {
+ let p: Vec = pts.iter().map(|i| self.p(*i)).collect();
+ if p.len() < 2 {
+ return q.dist(p.first().copied().unwrap_or_default());
+ }
+ if *control {
+ let k = (*degree as usize).min(solvecraft_geom::MAX_BEZIER_DEGREE).min(p.len().saturating_sub(1)).max(1);
+ crate::curves::bspline_beziers(&p, k).iter().filter_map(|span| solvecraft_geom::bezier(span)).collect()
+ } else {
+ crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect()
+ }
+ }
+ Some(CurveKind::Conic { a, b, apex, rho }) => {
+ vec![Seg2::Conic { a: self.p(*a), b: self.p(*b), apex: self.p(*apex), w: rho / (1.0 - rho).max(1e-9) }]
+ }
+ _ => Vec::new(),
+ };
+ segs.iter()
+ .map(|seg| {
+ let t = seg.closest_param(q);
+ let delta = q - seg.point_at(t);
+ let d = delta.len();
+ (d, if seg.derivative(t).cross(delta) >= 0.0 { d } else { -d })
+ })
+ .min_by(|a, b| a.0.total_cmp(&b.0))
+ .map(|(_, signed)| signed)
+ .unwrap_or_else(|| crate::curves::polyline_signed_dist(&self.poly(c), q))
+ }
/// Radius of a circle or arc; an ellipse's minor radius.
fn radius_of(&self, c: usize) -> f64 {
match self.sk.curves.get(c).map(|x| &x.kind) {
@@ -281,7 +326,7 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) {
PointOnCurve { p, c } => {
let pp = s.p(p);
if s.is_freeform(c) {
- out.push(crate::curves::polyline_signed_dist(&s.poly(c), pp));
+ out.push(s.freeform_dist(c, pp));
} else if s.is_line(c) {
let (a, b) = s.line(c);
out.push(line_dist(pp, a, b));
diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs
index 3b9ba9ab..cc936c9f 100644
--- a/crates/ui-egui/src/context_menu.rs
+++ b/crates/ui-egui/src/context_menu.rs
@@ -366,7 +366,7 @@ fn viewport_items(app: &SolveApp) -> Vec- {
v.push(act("ui.editFeature", "Edit Feature", "").with(json!({ "feature": feature })).on(feature.is_some()));
v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some()));
v.push(Item::sep());
- v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
+ v.push(act("ui.appearance", "Appearance", "appearance").key("A"));
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] })));
v.push(Item::sep());
v.push(act("ui.delete", "Delete", "delete").key("Del"));
@@ -437,7 +437,7 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec
- {
cmd(app, "component.from_bodies", "Create Components from Bodies").with(json!({ "bodies": names })),
Item::sep(),
act("ui.material", "Physical Material", "").with(json!({ "bodies": names })),
- act("ui.appearance", "Appearance", "").key("A").on(false),
+ act("ui.appearance", "Appearance", "appearance").key("A").with(json!({ "bodies": names })),
act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })),
Item::sep(),
act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })),
@@ -530,8 +530,13 @@ fn component_items(app: &SolveApp, id: u64) -> Vec
- {
}
v.push(Item::sep());
v.push(act("ui.material", "Physical Material", "").with(json!({ "bodies": names })).on(!bodies.is_empty()));
- v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
- v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })).on(!bodies.is_empty()));
+ v.push(
+ act("ui.appearance", "Appearance", "appearance")
+ .key("A")
+ .with(if id == 0 { json!({ "bodies": names }) } else { json!({"component": id}) })
+ .on(id != 0 || !bodies.is_empty()),
+ );
+ v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id })));
v.push(Item::sep());
v.push(act("ui.export", "Export…", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
@@ -716,10 +721,20 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
};
start_rename(app, what, at);
}
- "ui.properties" => match app.session.execute("inspect.measure", &json!({ "bodies": bodies })) {
- Ok(v) => app.menu.props = Some((if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) }, v)),
- Err(e) => app.set_status(e.to_string(), true),
- },
+ "ui.properties" => {
+ let command = if p.get("component").is_some() { "component.properties" } else { "inspect.measure" };
+ let params = if command == "component.properties" { p.clone() } else { json!({ "bodies": bodies }) };
+ match app.session.execute(command, ¶ms) {
+ Ok(v) => {
+ let title = v["name"]
+ .as_str()
+ .map(str::to_string)
+ .unwrap_or_else(|| if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) });
+ app.menu.props = Some((title, v));
+ }
+ Err(e) => app.set_status(e.to_string(), true),
+ }
+ }
"ui.export" => {
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"]))
@@ -847,6 +862,26 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
app.edit_feature(f);
}
}
+ "ui.appearance" => {
+ if item.params.is_some() {
+ let items: Vec
= bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
+ let _ = app.run("select.set", json!({ "items": items }));
+ }
+ app.start("appearance.assign");
+ if let Some(name) = id_of(&p, "component").and_then(|id| app.session.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()))
+ && let Some(d) = app.dialog.as_mut()
+ && let crate::dialogs::Kind::Appearance(k) = &mut d.kind
+ {
+ if let Some(id) = id_of(&p, "component")
+ && let Some(look) = app.session.doc.appearances.components.get(&id)
+ {
+ k.color = look.color;
+ k.opacity = look.opacity;
+ k.look = Some(look.name.clone());
+ }
+ k.component = Some(name);
+ }
+ }
"ui.material" => {
let items: Vec = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
let _ = app.run("select.set", json!({ "items": items }));
@@ -1502,7 +1537,8 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) {
ui.label("Area");
ui.label(crate::prefs::show_mm(app, f(total, "area_mm2"), 2));
ui.end_row();
- if let [b] = bodies.as_slice() {
+ let geometry = if total.get("center_of_mass").is_some() { Some(total) } else { bodies.first().filter(|_| bodies.len() == 1) };
+ if let Some(b) = geometry {
if let Some(c) = b["center_of_mass"].as_array() {
let c: Vec = c.iter().map(|x| crate::prefs::show_mm_bare(app, x.as_f64().unwrap_or(f64::NAN))).collect();
ui.label("Center of mass");
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 5038b79e..520b081d 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1226,6 +1226,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
.stroke(Stroke::new(1.0, t.dialog_border))
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
+ // The area includes the border as well as the inner margins; reserve all of it so a
+ // horizontally scrolling table cannot push a wide dialog two pixels past its limit.
+ let horizontal_frame = frame.inner_margin.leftf() + frame.inner_margin.rightf() + 2.0 * frame.stroke.width;
// OK is offered once the inputs make a feature.
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
@@ -1245,7 +1248,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.spacing_mut().text_edit_width = FIELD_W;
// Long texts (errors, notes) wrap instead of widening the dialog.
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
- let margin = 2.0 * ui.style().spacing.window_margin.leftf();
+ let margin = horizontal_frame;
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
ui.set_min_width(min_w);
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
@@ -1268,7 +1271,15 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
ui.add_space(2.0);
+ // Configuration tables own their column widths and scrolling; the two-column form
+ // grid would squeeze their row names and action buttons into a label-sized cell.
+ if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
+ enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
+ }
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
+ if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
+ return;
+ }
placement_row(&mut d, ui);
input_rows(&mut d, ui);
match &mut d.kind {
diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs
index 6890658e..b55b42e1 100644
--- a/crates/ui-egui/src/dialogs_appearance.rs
+++ b/crates/ui-egui/src/dialogs_appearance.rs
@@ -19,12 +19,12 @@ pub struct Look {
pub name: String,
pub category: String,
pub color: [u8; 3],
- pub opacity: f32,
+ pub opacity: f64,
}
/// Built-in looks, used until the engine offers a library (`appearance.library`).
fn builtin() -> Vec {
- let l = |cat: &str, name: &str, c: [u8; 3], o: f32| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
+ let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
vec![
l("Metal", "Aluminum - Satin", [196, 199, 204], 1.0),
l("Metal", "Steel - Brushed", [160, 164, 170], 1.0),
@@ -63,7 +63,7 @@ pub fn library(app: &mut SolveApp) -> Vec {
name: x.get("name")?.as_str()?.to_string(),
category: x.get("category").and_then(Value::as_str).unwrap_or("Library").to_string(),
color: parse_color(x.get("color")?)?,
- opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32,
+ opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
})
})
.collect();
@@ -96,27 +96,61 @@ pub struct Ap {
/// Apply to faces instead of bodies.
pub faces: bool,
pub color: [u8; 3],
- pub opacity: f32,
+ pub opacity: f64,
/// The look picked last (its name), if the colour came from one.
pub look: Option,
/// Library category shown (0: all).
pub category: usize,
+ /// Library names can be searched without changing the selected look.
+ pub search: String,
+ /// A Browser component target, inherited by its bodies unless they have overrides.
+ pub component: Option,
+ pub remove_override: bool,
+ /// The last successful immediate Apply/drop, so OK does not duplicate its undo step.
+ pub applied: Option,
/// What the last drop or Apply did.
pub note: Option,
}
/// The dialog for the Appearance command.
-pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> {
+pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> {
(id == "appearance.assign").then(|| {
+ let faces = app.session.selection.iter().any(|s| matches!(s, Sel::Face { .. }))
+ && !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. }));
+ let state = app.session.world_state();
+ let existing = app.session.selection.iter().find_map(|sel| match sel {
+ Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.body_look(name, b.feature)),
+ Sel::Face { body, index, .. } => state.body(body).and_then(|b| {
+ app.session
+ .doc
+ .face_colors(b)
+ .into_iter()
+ .find(|(i, _)| i == index)
+ .map(|(_, look)| look)
+ .or_else(|| app.session.doc.body_look(body, b.feature))
+ }),
+ _ => None,
+ });
(
- Kind::Appearance(Ap { faces: false, color: [196, 199, 204], opacity: 1.0, look: None, category: 0, note: None }),
- vec![SelInput::new("Objects", BODIES, true)],
+ Kind::Appearance(Ap {
+ faces,
+ color: existing.as_ref().map_or([196, 199, 204], |l| l.color),
+ opacity: existing.as_ref().map_or(1.0, |l| l.opacity),
+ look: existing.map(|l| l.name),
+ category: 0,
+ search: String::new(),
+ component: None,
+ remove_override: false,
+ applied: None,
+ note: None,
+ }),
+ vec![SelInput::new("Objects", if faces { FACES } else { BODIES }, true)],
)
})
}
/// The command giving `targets` (bodies or faces) a look.
-fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str>) -> Option {
+fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str>) -> Option {
let bodies: Vec = targets.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
let faces: Vec = targets
.iter()
@@ -139,9 +173,7 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str
p["bodies"] = json!(owners);
}
}
- if opacity < 0.999 {
- p["opacity"] = json!((opacity * 1000.0).round() / 1000.0);
- }
+ p["opacity"] = json!(opacity);
if let Some(n) = look {
p["appearance"] = json!(n);
}
@@ -150,8 +182,35 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str
/// The commands OK runs: the current look on the picked objects (nothing when none are picked).
pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
+ // Remove override already ran immediately; OK closes without creating a second undo step.
+ if k.remove_override {
+ return Vec::new();
+ }
let items = inputs.first().map(|i| i.items.as_slice()).unwrap_or(&[]);
- apply_params(items, k.color, k.opacity, k.look.as_deref()).map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
+ let p = selected_params(k, items);
+ if p.as_ref().is_some_and(|p| k.applied.as_ref() == Some(p)) {
+ return Vec::new();
+ }
+ p.map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
+}
+
+fn selected_params(k: &Ap, items: &[Sel]) -> Option {
+ if let Some(component) = &k.component {
+ Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
+ } else {
+ apply_params(items, k.color, k.opacity, k.look.as_deref())
+ }
+}
+
+/// Removing an override restores the inherited component or material appearance.
+fn clear_params(k: &Ap, items: &[Sel]) -> Option {
+ let mut p = selected_params(k, items)?;
+ if let Some(obj) = p.as_object_mut() {
+ obj.remove("appearance");
+ obj.remove("color");
+ obj.remove("opacity");
+ }
+ Some(p)
}
/// What a swatch dropped now would land on: the face or body under the cursor in the viewport,
@@ -166,32 +225,43 @@ fn drop_target(app: &SolveApp, faces: bool) -> Vec {
/// The looks in this design (colour and how many items wear it): the engine's list when it has
/// one, else the body colours.
-fn in_design(app: &mut SolveApp) -> Vec<([u8; 3], usize)> {
+fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> {
if let Ok(v) = app.session.execute("appearance.list", &json!({}))
&& let Some(list) = v.get("in_design").and_then(Value::as_array)
{
let count = |x: &Value, k: &str| x.get(k).and_then(Value::as_array).map_or(0, Vec::len);
return list
.iter()
- .filter_map(|x| Some((parse_color(x.get("color")?)?, count(x, "bodies") + count(x, "faces") + count(x, "components"))))
+ .filter_map(|x| {
+ Some((
+ Look {
+ name: x.get("name").and_then(Value::as_str).unwrap_or("Custom").to_string(),
+ category: "In This Design".into(),
+ color: parse_color(x.get("color")?)?,
+ opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
+ },
+ count(x, "bodies") + count(x, "faces") + count(x, "components"),
+ ))
+ })
.collect();
}
- let mut out: Vec<([u8; 3], usize)> = Vec::new();
- for c in app.session.doc.appearances.bodies.values().map(|l| l.color) {
- match out.iter_mut().find(|(x, _)| *x == c) {
+ let mut out: Vec<(Look, usize)> = Vec::new();
+ for l in app.session.doc.appearances.bodies.values() {
+ let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity };
+ match out.iter_mut().find(|(x, _)| *x == look) {
Some(e) => e.1 += 1,
- None => out.push((c, 1)),
+ None => out.push((look, 1)),
}
}
out
}
-fn c32(c: [u8; 3], opacity: f32) -> Color32 {
+fn c32(c: [u8; 3], opacity: f64) -> Color32 {
Color32::from_rgba_unmultiplied(c[0], c[1], c[2], (opacity.clamp(0.0, 1.0) * 255.0) as u8)
}
/// A swatch: a rounded square of the look; returns its response (click, drag).
-fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked: bool, tip: &str, payload: Look) -> egui::Response {
+fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f64, picked: bool, tip: &str, payload: Look) -> egui::Response {
let t = Tokens::get();
let r = ui
.dnd_drag_source(id, payload, |ui| {
@@ -200,7 +270,7 @@ fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked:
if opacity < 0.999 {
let h = rect.width() / 2.0;
for (dx, dy) in [(0.0, 0.0), (h, h)] {
- ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, Color32::from_gray(200));
+ ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, t.hover);
}
}
ui.painter().rect(
@@ -238,13 +308,23 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
let t = Tokens::get();
// Apply To: bodies or faces (the input follows).
row_label(ui, "Apply To");
- let mut mode = usize::from(k.faces);
- combo(ui, "ap_mode", &["Bodies/Components", "Faces"], &mut mode);
- ui.end_row();
- if (mode == 1) != k.faces {
- k.faces = mode == 1;
- if let Some(i) = inputs.first_mut() {
- *i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true);
+ if let Some(component) = &k.component {
+ ui.label(format!("Component: {component}"));
+ ui.end_row();
+ row_label(ui, "");
+ if ui.button("Select bodies or faces instead").clicked() {
+ k.component = None;
+ }
+ ui.end_row();
+ } else {
+ let mut mode = usize::from(k.faces);
+ combo(ui, "ap_mode", &["Bodies", "Faces"], &mut mode);
+ ui.end_row();
+ if (mode == 1) != k.faces {
+ k.faces = mode == 1;
+ if let Some(i) = inputs.first_mut() {
+ *i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true);
+ }
}
}
// The current look: colour and opacity.
@@ -254,35 +334,71 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
if ui.color_edit_button_srgb(&mut rgb).changed() {
k.color = rgb;
k.look = None;
+ k.remove_override = false;
}
ui.label(RichText::new(k.look.clone().unwrap_or_else(|| hex(k.color))).color(t.text_dim));
});
ui.end_row();
row_label(ui, "Opacity");
ui.spacing_mut().slider_width = 110.0;
- ui.add(egui::Slider::new(&mut k.opacity, 0.05..=1.0).fixed_decimals(2));
+ if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).fixed_decimals(2)).changed() {
+ k.remove_override = false;
+ }
ui.end_row();
row_label(ui, "");
let picked: Vec = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
- if ui.add_enabled(!picked.is_empty(), egui::Button::new("Apply to selection")).clicked()
- && let Some(p) = apply_params(&picked, k.color, k.opacity, k.look.as_deref())
- {
- k.note = Some(match app.run("appearance.assign", p) {
- Ok(_) => format!("applied to {} item(s)", picked.len()),
- Err(e) => e,
- });
- }
+ ui.horizontal(|ui| {
+ let has_target = !picked.is_empty() || k.component.is_some();
+ if ui.add_enabled(has_target, egui::Button::new("Apply to selection")).clicked()
+ && let Some(p) = selected_params(k, &picked)
+ {
+ k.remove_override = false;
+ k.note = Some(match app.run("appearance.assign", p.clone()) {
+ Ok(_) => {
+ k.applied = Some(p);
+ "Appearance applied".into()
+ }
+ Err(e) => e,
+ });
+ }
+ if ui
+ .add_enabled(has_target, egui::Button::new("Remove override"))
+ .on_hover_text("Restore the inherited component or physical material appearance")
+ .clicked()
+ && let Some(p) = clear_params(k, &picked)
+ {
+ k.note = Some(match app.run("appearance.assign", p) {
+ Ok(_) => {
+ k.remove_override = true;
+ k.applied = None;
+ "Inherited appearance restored".into()
+ }
+ Err(e) => e,
+ });
+ }
+ });
ui.end_row();
// In this design.
let used = in_design(app);
if !used.is_empty() {
row_label(ui, "In This Design");
- let items: Vec<(usize, &([u8; 3], usize))> = used.iter().enumerate().collect();
- swatch_rows(ui, &items, |ui, (i, (c, n))| {
- let look = Look { name: hex(*c), category: "In This Design".into(), color: *c, opacity: 1.0 };
- if swatch(ui, egui::Id::new(("ap_used", *i)), *c, 1.0, k.color == *c, &format!("{} · {n} item(s)", hex(*c)), look).clicked() {
- k.color = *c;
- k.look = None;
+ let items: Vec<_> = used.iter().enumerate().collect();
+ swatch_rows(ui, &items, |ui, (i, (look, n))| {
+ if swatch(
+ ui,
+ egui::Id::new(("ap_used", *i)),
+ look.color,
+ look.opacity,
+ k.color == look.color && k.opacity == look.opacity,
+ &format!("{} · {n} item(s)", look.name),
+ look.clone(),
+ )
+ .clicked()
+ {
+ k.color = look.color;
+ k.opacity = look.opacity;
+ k.look = Some(look.name.clone());
+ k.remove_override = false;
}
});
ui.end_row();
@@ -299,9 +415,18 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
row_label(ui, "Library");
combo(ui, "ap_cat", &refs, &mut k.category);
ui.end_row();
+ row_label(ui, "Search");
+ let search = ui.add(egui::TextEdit::singleline(&mut k.search).hint_text("Appearance name").desired_width(190.0));
+ crate::scenario::publish_handle("appearance:search", search.rect.center());
+ ui.end_row();
row_label(ui, "");
let want = cats.get(k.category).cloned().unwrap_or_default();
- let shown: Vec<(usize, &Look)> = lib.iter().enumerate().filter(|(_, l)| k.category == 0 || l.category == want).collect();
+ let query = k.search.trim().to_lowercase();
+ let shown: Vec<(usize, &Look)> =
+ lib.iter().enumerate().filter(|(_, l)| (k.category == 0 || l.category == want) && l.name.to_lowercase().contains(&query)).collect();
+ if shown.is_empty() {
+ ui.label(RichText::new("No matching appearances").color(t.text_dim));
+ }
swatch_rows(ui, &shown, |ui, (i, l)| {
let picked_look = k.look.as_deref() == Some(l.name.as_str());
if swatch(ui, egui::Id::new(("ap_lib", *i)), l.color, l.opacity, picked_look, &format!("{} (drag onto a body or face)", l.name), (*l).clone())
@@ -310,6 +435,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
k.color = l.color;
k.opacity = l.opacity;
k.look = Some(l.name.clone());
+ k.remove_override = false;
}
});
ui.end_row();
@@ -319,10 +445,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
let targets = drop_target(app, k.faces);
k.color = look.color;
k.opacity = look.opacity;
- k.look = (look.category != "In This Design").then(|| look.name.clone());
+ k.look = Some(look.name.clone());
+ k.remove_override = false;
if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
- k.note = Some(match app.run("appearance.assign", p) {
- Ok(_) => format!("{} on {}", look.name, target_names(&targets)),
+ k.note = Some(match app.run("appearance.assign", p.clone()) {
+ Ok(_) => {
+ k.applied = Some(p);
+ format!("{} on {}", look.name, target_names(&targets))
+ }
Err(e) => e,
});
}
@@ -392,7 +522,11 @@ mod tests {
a.run(&id, p).unwrap();
}
assert_eq!(a.session.doc.appearances.bodies.get("Body2").map(|l| l.color), Some([196, 40, 36]));
- assert_eq!(in_design(&mut a), vec![([196, 40, 36], 2)]);
+ let used = in_design(&mut a);
+ assert_eq!(used.len(), 1);
+ assert_eq!(used[0].0.color, [196, 40, 36]);
+ assert_eq!(used[0].0.name, "Paint - Red");
+ assert_eq!(used[0].1, 2);
}
/// A swatch dropped on a face in face mode targets the face; otherwise its body.
@@ -411,4 +545,73 @@ mod tests {
assert_eq!(face["opacity"], 0.5);
assert!(!library(&mut a).is_empty());
}
+
+ #[test]
+ fn face_preselection_keeps_the_face_and_remove_survives_ok() {
+ let mut a = app();
+ let point = Vec3::new(5.0, 5.0, 10.0);
+ let selected = vec![Sel::Face { body: "Body1".into(), index: 0, point }];
+ a.session.selection = selected.clone();
+ a.start("appearance.assign");
+ let mut d = a.dialog.clone().unwrap();
+ assert_eq!(d.inputs[0].items, selected);
+ let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") };
+ assert!(k.faces);
+ k.color = [10, 20, 30];
+ k.opacity = 0.25;
+ for (id, p) in commands(k, &d.inputs) {
+ a.run(&id, p).unwrap();
+ }
+ assert_eq!(a.session.doc.appearances.faces.len(), 1);
+ assert!(a.session.doc.appearances.bodies.is_empty());
+ a.run("appearance.assign", clear_params(k, &d.inputs[0].items).unwrap()).unwrap();
+ k.remove_override = true;
+ assert!(commands(k, &d.inputs).is_empty());
+ assert!(a.session.doc.appearances.faces.is_empty());
+ a.run("edit.undo", json!({})).unwrap();
+ assert_eq!(a.session.doc.appearances.faces.len(), 1);
+ }
+
+ #[test]
+ fn reused_design_look_preserves_transparency_and_name() {
+ let mut a = app();
+ a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "appearance": "Tinted glass", "opacity": 0.25})).unwrap();
+ let used = in_design(&mut a);
+ let look = &used[0].0;
+ assert_eq!(look.opacity, 0.25);
+ assert_eq!(look.name, "Tinted glass");
+ a.session.selection = vec![Sel::Body { name: "Body1".into() }];
+ a.start("appearance.assign");
+ let Kind::Appearance(k) = &a.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
+ assert_eq!(k.opacity, 0.25);
+ assert_eq!(k.look.as_deref(), Some("Tinted glass"));
+ let p = apply_params(&[Sel::Body { name: "Body2".into() }], look.color, look.opacity, Some(&look.name)).unwrap();
+ a.run("appearance.assign", p).unwrap();
+ assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
+ }
+
+ #[test]
+ fn immediate_apply_then_ok_has_one_undo_and_retains_precise_opacity() {
+ let mut a = app();
+ a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "opacity": 0.123456})).unwrap();
+ a.session.selection = vec![Sel::Body { name: "Body1".into() }];
+ a.start("appearance.assign");
+ let mut d = a.dialog.clone().unwrap();
+ let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") };
+ assert_eq!(commands(k, &d.inputs)[0].1["opacity"], json!(0.123456));
+ k.color = [1, 2, 3];
+ let p = selected_params(k, &d.inputs[0].items).unwrap();
+ a.run("appearance.assign", p.clone()).unwrap();
+ k.applied = Some(p);
+ assert!(commands(k, &d.inputs).is_empty());
+ a.run("edit.undo", json!({})).unwrap();
+ let restored = a.session.doc.appearances.bodies.get("Body1").unwrap();
+ assert_eq!(restored.color, [171, 205, 239]);
+ assert_eq!(restored.opacity, 0.123456);
+ k.opacity = 0.5;
+ assert_eq!(commands(k, &d.inputs).len(), 1, "changed look still applies on OK");
+ k.opacity = 0.123456;
+ d.inputs[0].items = vec![Sel::Body { name: "Body2".into() }];
+ assert_eq!(commands(k, &d.inputs).len(), 1, "changed target still applies on OK");
+ }
}
diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs
index 5331e429..d0a3bb5d 100644
--- a/crates/ui-egui/src/dialogs_motion.rs
+++ b/crates/ui-egui/src/dialogs_motion.rs
@@ -597,56 +597,115 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
let run = |app: &mut SolveApp, error: &mut Option, id: &str, p: Value| {
*error = app.run(id, p).err();
};
- // Header.
- ui.label(RichText::new("Configuration").strong());
- ui.horizontal(|ui| {
- for c in &tb.columns {
- let (kind, n) = column_parts(c);
- ui.add_sized(vec2(96.0, 18.0), egui::Label::new(RichText::new(n).strong()).truncate())
- .on_hover_text(format!("{} {n}", if kind == "suppress" { "Suppress" } else { "Parameter" }));
- if ui.small_button("×").on_hover_text("Remove the column").clicked() {
- let key = if kind == "suppress" { "feature" } else { "param" };
- run(app, error, "config.column", json!({ key: n, "delete": true }));
- }
- }
- });
- ui.end_row();
- for (name, vals) in &tb.rows {
- let active = tb.active.as_deref() == Some(name.as_str());
- ui.horizontal(|ui| {
- ui.label(if active { RichText::new(name).strong() } else { RichText::new(name) });
- if active {
- ui.label(RichText::new("Active").color(t.accent));
- } else if ui.button("Activate").clicked() {
- run(app, error, "config.activate", json!({ "row": name }));
- }
- });
- ui.horizontal(|ui| {
- for (c, val) in tb.columns.iter().zip(vals) {
- let id = egui::Id::new(("cfg", name, c));
- let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| val.clone());
- let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(96.0));
- if r.changed() {
- ui.data_mut(|d| d.insert_temp(id, text.clone()));
+ egui::ScrollArea::horizontal().id_salt("configuration_columns").auto_shrink([false, true]).show(ui, |ui| {
+ ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
+ egui::Grid::new("configuration_table").num_columns(2).min_col_width(210.0).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
+ // Header.
+ ui.label(RichText::new("Configuration").strong());
+ ui.horizontal(|ui| {
+ for c in &tb.columns {
+ ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
+ let (kind, n) = column_parts(c);
+ let label = match kind {
+ "material" => format!("Material: {n}"),
+ "appearance" => format!("Appearance: {n}"),
+ "suppress" => format!("Suppress: {n}"),
+ _ => n.to_string(),
+ };
+ ui.add_sized(vec2(156.0, 22.0), egui::Label::new(RichText::new(label).strong()).truncate()).on_hover_text(c);
+ if ui.small_button("×").on_hover_text("Remove the column").clicked() {
+ let key = if kind == "suppress" { "feature" } else { kind };
+ run(app, error, "config.column", json!({ key: n, "delete": true }));
+ }
+ });
}
- if r.lost_focus() {
- ui.data_mut(|d| d.remove::(id));
- if text != *val {
- let (_, n) = column_parts(c);
- run(app, error, "config.row", json!({"name": name, "values": { n: cell_value(c, &text) }}));
+ });
+ ui.end_row();
+ for (name, vals) in &tb.rows {
+ let active = tb.active.as_deref() == Some(name.as_str());
+ ui.horizontal(|ui| {
+ ui.add_sized(
+ vec2(100.0, 22.0),
+ egui::Label::new(if active { RichText::new(name).strong() } else { RichText::new(name) }).truncate(),
+ )
+ .on_hover_text(name);
+ if active {
+ ui.label(RichText::new("Active").color(t.accent));
+ if v.get("matches").and_then(Value::as_bool) == Some(false) && ui.button("Apply").clicked() {
+ run(app, error, "config.activate", json!({ "row": name }));
+ }
+ } else if ui.button("Activate").clicked() {
+ run(app, error, "config.activate", json!({ "row": name }));
}
- }
- ui.add_space(18.0);
- }
- if ui.small_button("×").on_hover_text("Delete the row").clicked() {
- run(app, error, "config.row", json!({"name": name, "delete": true}));
+ });
+ ui.horizontal(|ui| {
+ for (c, val) in tb.columns.iter().zip(vals) {
+ ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
+ let (kind, _) = column_parts(c);
+ let shown = if kind == "appearance" {
+ solvecraft_engine::doc::config::appearance_cell(val)
+ .ok()
+ .flatten()
+ .filter(|look| solvecraft_engine::doc::appearance::library(&look.name).as_ref() == Some(look))
+ .map(|look| look.name)
+ .unwrap_or_else(|| val.clone())
+ } else {
+ val.clone()
+ };
+ let style = matches!(kind, "material" | "appearance");
+ if style {
+ let mut choice = None;
+ ui.menu_button("Library", |ui| {
+ if ui.button(if kind == "appearance" { "Follow material" } else { "Default material" }).clicked() {
+ choice = Some("Default".to_string());
+ ui.close();
+ }
+ if kind == "material" {
+ for (name, _) in solvecraft_engine::doc::MATERIALS.iter().filter(|(n, _)| *n != "Default") {
+ if ui.button(*name).clicked() {
+ choice = Some(name.to_string());
+ ui.close();
+ }
+ }
+ } else {
+ for (name, _, _) in solvecraft_engine::doc::appearance::LIBRARY {
+ if ui.button(name).clicked() {
+ choice = Some(name.to_string());
+ ui.close();
+ }
+ }
+ }
+ });
+ if let Some(choice) = choice {
+ run(app, error, "config.row", json!({"name": name, "values": { c: choice }}));
+ }
+ }
+ let id = egui::Id::new(("cfg", name, c));
+ let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone());
+ let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(if style { 106.0 } else { 164.0 }));
+ if r.changed() {
+ ui.data_mut(|d| d.insert_temp(id, text.clone()));
+ }
+ if r.lost_focus() {
+ ui.data_mut(|d| d.remove::(id));
+ if text != shown {
+ run(app, error, "config.row", json!({"name": name, "values": { c: cell_value(c, &text) }}));
+ }
+ }
+ });
+ }
+ if ui.small_button("×").on_hover_text("Delete the row").clicked() {
+ run(app, error, "config.row", json!({"name": name, "delete": true}));
+ }
+ });
+ ui.end_row();
}
});
- ui.end_row();
- }
+ });
// New row (a copy of the active one) and new column.
- ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(130.0));
ui.horizontal(|ui| {
+ ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
+ ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(160.0));
if ui.button("Add Row").on_hover_text("A new configuration, a copy of the active one").clicked() {
let name = if new_row.trim().is_empty() {
(tb.rows.len() + 1..).map(|k| format!("Configuration{k}")).find(|n| !tb.rows.iter().any(|r| &r.0 == n)).unwrap_or_default()
@@ -672,6 +731,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
options.push((format!("Suppress {}", f.name), json!({ "feature": f.name })));
}
}
+ for body in &app.session.model.state().bodies {
+ for kind in ["material", "appearance"] {
+ if !tb.columns.contains(&format!("{kind}:{}", body.name)) {
+ options.push((format!("{} ({kind})", body.name), json!({kind: body.name})));
+ }
+ }
+ }
let mut pick: Option = None;
ui.menu_button("Add Column", |ui| {
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
@@ -688,9 +754,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
let (id, p) = if tb.columns.is_empty() {
(
"config.configure",
- match p.get("param") {
- Some(n) => json!({ "params": [n] }),
- None => json!({ "features": [p.get("feature")] }),
+ if let Some(n) = p.get("param") {
+ json!({ "params": [n] })
+ } else if let Some(n) = p.get("feature") {
+ json!({ "features": [n] })
+ } else if let Some(n) = p.get("material") {
+ json!({ "materials": [n] })
+ } else {
+ json!({ "appearances": [p.get("appearance")] })
},
)
} else {
@@ -837,6 +908,86 @@ mod tests {
assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm"));
}
+ #[test]
+ fn configuration_style_picker_applies_then_inherits_material() {
+ let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap();
+ let mut harness = crate::scenario::Harness::new();
+ for step in steps.as_array().unwrap() {
+ harness.step(step).unwrap();
+ match step.get("shot").and_then(Value::as_str) {
+ Some("configuration_red") => {
+ let look = harness.app.session.doc.appearances.bodies.get("Block").unwrap();
+ assert_eq!(look.color, [196, 40, 40]);
+ assert!(harness.app.session.doc.configuration_matches());
+ }
+ Some("configuration_default") => {
+ assert!(!harness.app.session.doc.appearances.bodies.contains_key("Block"));
+ let state = harness.app.session.model.state();
+ let body = state.body("Block").unwrap();
+ let look = harness.app.session.doc.body_look("Block", body.feature).unwrap();
+ assert_eq!(look.name, "Steel");
+ assert_eq!(look.color, [158, 164, 172]);
+ assert!(harness.app.session.doc.configuration_matches());
+ }
+ _ => {}
+ }
+ }
+ }
+
+ #[test]
+ fn configuration_table_actions_stay_on_one_line_at_normal_and_narrow_widths() {
+ for width in [1400, 900] {
+ let mut harness = crate::scenario::Harness::new();
+ harness.call("ui.resize", json!({"width": width, "height": 720}));
+ harness
+ .run(&json!([
+ {"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":20,"height":10,"body_name":"Block"}}},
+ {"start":"config.table"}
+ ]))
+ .unwrap();
+ assert_single_line(&harness, "Configuration");
+ for name in ["span", "thickness", "depth", "clearance"] {
+ harness.call("engine.execute", json!({"command":"parameters.change","params":{"name":name,"expression":"10 mm"}}));
+ }
+ harness
+ .run(&json!([
+ {"call":"engine.execute","params":{"command":"config.configure","params":{
+ "params":["span","thickness","depth","clearance"],"materials":["Block"],"appearances":["Block"]
+ }}},
+ {"call":"engine.execute","params":{"command":"config.row","params":{"name":"Default","values":{"param:span":"20 mm"}}}}
+ ]))
+ .unwrap();
+ harness.frames(4);
+ for label in ["Configuration", "Active", "Apply", "Add Row", "Add Column"] {
+ assert_single_line(&harness, label);
+ }
+ crate::scenario::check(&mut harness, &json!({"windows_fit":{"max_width":560}})).unwrap();
+ for name in ["span", "thickness", "depth", "clearance"] {
+ harness.call("engine.execute", json!({"command":"config.column","params":{"param":name,"delete":true}}));
+ }
+ harness.call("engine.execute", json!({"command":"config.column","params":{"appearance":"Block","delete":true}}));
+ assert_single_line(&harness, "Material: Block");
+ harness.call("engine.execute", json!({"command":"config.column","params":{"material":"Block","delete":true}}));
+ harness.call("engine.execute", json!({"command":"config.column","params":{"feature":"Box1"}}));
+ assert_single_line(&harness, "Suppress: Box1");
+ }
+ fn assert_single_line(harness: &crate::scenario::Harness, label: &str) {
+ let tree = harness.output.accesskit_update.as_ref().unwrap();
+ let bounds = tree
+ .nodes
+ .iter()
+ .filter(|(_, n)| n.label() == Some(label) || n.value() == Some(label))
+ .filter_map(|(_, n)| n.bounds())
+ .collect::>();
+ assert!(
+ !bounds.is_empty(),
+ "missing {label}; labels/values: {:?}",
+ tree.nodes.iter().filter_map(|(_, n)| n.label().or(n.value())).collect::>()
+ );
+ assert!(bounds.iter().all(|b| b.y1 - b.y0 <= 24.0), "{label} wrapped: {bounds:?}");
+ }
+ }
+
#[test]
fn study_keys_by_joint_id() {
let mut s = Session::default();
diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs
index e6536edb..55a6bde0 100644
--- a/crates/ui-egui/src/dialogs_sheet.rs
+++ b/crates/ui-egui/src/dialogs_sheet.rs
@@ -360,7 +360,12 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
}
row_label(ui, "");
if ui.button("Export DXF…").clicked() {
- *status = Some(export_dxf(app, v["body"].as_str().unwrap_or("")));
+ *status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "dxf"));
+ }
+ ui.end_row();
+ row_label(ui, "");
+ if ui.button("Export Bend Table…").clicked() {
+ *status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "csv"));
}
ui.end_row();
if let Some(s) = status {
@@ -381,12 +386,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
enter
}
-fn export_dxf(app: &mut SolveApp, body: &str) -> String {
- let name = format!("{}-{body}.dxf", app.session.doc.name);
- let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &["dxf"])) else {
+fn export_flat(app: &mut SolveApp, body: &str, extension: &str) -> String {
+ let name = format!("{}-{body}.{extension}", app.session.doc.name);
+ let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[extension])) else {
return "Export cancelled".into();
};
- match app.run("sheet.export_dxf", json!({"body": body, "path": path})) {
+ let command = if extension == "csv" { "sheet.export_bend_table" } else { "sheet.export_dxf" };
+ match app.run(command, json!({"body": body, "path": path})) {
Ok(_) => {
app.set_status(format!("Exported {path}"), false);
format!("Saved {path}")
diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs
index e4653ee6..9d6325ab 100644
--- a/crates/ui-egui/src/dialogs_sheet_tests.rs
+++ b/crates/ui-egui/src/dialogs_sheet_tests.rs
@@ -106,3 +106,46 @@ fn hem_and_rules() {
run_all(&mut app, c);
assert!(app.session.doc.sheet.rules.iter().any(|r| r.name == "Alu"));
}
+
+#[test]
+fn flat_exports_use_the_save_picker() {
+ use std::sync::{Arc, Mutex};
+ let mut app = plate();
+ app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
+ let picks = Arc::new(Mutex::new(Vec::new()));
+ let captured = picks.clone();
+ app.services.pick_save = Some(Box::new(move |name, extensions| {
+ captured.lock().unwrap().push((name.to_string(), extensions.iter().map(|x| x.to_string()).collect::>()));
+ None
+ }));
+ assert_eq!(export_flat(&mut app, "Base", "csv"), "Export cancelled");
+ assert_eq!(export_flat(&mut app, "Base", "dxf"), "Export cancelled");
+ let picks = picks.lock().unwrap();
+ assert!(picks[0].0.ends_with("-Base.csv"));
+ assert_eq!(picks[0].1, vec!["csv"]);
+ assert!(picks[1].0.ends_with("-Base.dxf"));
+ assert_eq!(picks[1].1, vec!["dxf"]);
+}
+
+#[test]
+#[cfg(not(target_arch = "wasm32"))]
+fn bend_table_picker_writes_the_current_body() {
+ let mut app = plate();
+ app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
+ app.run("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})).unwrap();
+ let body = app.session.model.state().sheets[0].body.clone();
+ let path = std::env::temp_dir().join(format!("solvecraft-bend-table-{}.csv", std::process::id()));
+ let output = path.to_string_lossy().into_owned();
+ let chosen = output.clone();
+ app.services.pick_save = Some(Box::new(move |_, extensions| {
+ assert_eq!(extensions, &["csv"]);
+ Some(chosen.clone())
+ }));
+ let undo = app.session.undo.len();
+ assert_eq!(export_flat(&mut app, &body, "csv"), format!("Saved {output}"));
+ let contents = std::fs::read_to_string(&path).unwrap();
+ std::fs::remove_file(&path).unwrap();
+ assert_eq!(contents.lines().count(), 2);
+ assert!(contents.contains(",90.000000000,up,2.500000000,"), "{contents}");
+ assert_eq!(app.session.undo.len(), undo);
+}
diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs
index 3829dd59..64c4a7be 100644
--- a/crates/ui-egui/src/menu_tests.rs
+++ b/crates/ui-egui/src/menu_tests.rs
@@ -45,6 +45,32 @@ fn empty_space_offers_view_items_and_greys_out_undo() {
assert!(!app.ui.show_origin);
}
+#[test]
+fn appearance_browser_action_targets_the_clicked_body() {
+ let mut app = sample_app();
+ let b = body(&app);
+ app.run("select.clear", json!({})).unwrap();
+ act(&mut app, &Target::Body { name: b.clone() }, "Appearance");
+ let dialog = app.dialog.as_ref().unwrap();
+ assert!(matches!(dialog.kind, crate::dialogs::Kind::Appearance(_)));
+ assert_eq!(dialog.inputs[0].items, vec![solvecraft_engine::Sel::Body { name: b }]);
+}
+
+#[test]
+fn appearance_component_action_assigns_an_inherited_look() {
+ let mut app = sample_app();
+ let created = app.run("component.create", json!({"name": "Painted part"})).unwrap();
+ let id = created["component"].as_u64().unwrap();
+ act(&mut app, &Target::Component { id }, "Appearance");
+ let d = app.dialog.clone().unwrap();
+ let commands = crate::dialogs::apply_commands(&app, &d).unwrap();
+ assert_eq!(commands[0].1["components"], json!(["Painted part"]));
+ for (id, p) in commands {
+ app.run(&id, p).unwrap();
+ }
+ assert!(app.session.doc.appearances.components.contains_key(&id));
+}
+
#[test]
fn face_menu_hides_and_finds_the_body() {
let mut app = sample_app();
@@ -54,7 +80,7 @@ fn face_menu_hides_and_finds_the_body() {
for l in ["Create Sketch", "Offset Plane", "Press Pull", "Extrude", "Measure", "Find in Browser", "Find in Timeline"] {
assert!(find(&items, l).enabled, "{l}");
}
- assert!(!find(&items, "Appearance").enabled);
+ assert!(find(&items, "Appearance").enabled);
context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0));
assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }]));
context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0));
@@ -97,6 +123,24 @@ fn properties_measure_the_body() {
assert!(v["total"]["mass_g"].as_f64().unwrap() > 0.0);
}
+#[test]
+fn component_properties_measure_one_assembly_placement() {
+ let mut session = Session::default();
+ let component = session.execute("component.create", &json!({"name": "Subassembly"})).unwrap()["component"].as_u64().unwrap();
+ session.execute("component.create", &json!({"name": "Block"})).unwrap();
+ session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
+ session.execute("occurrence.copy", &json!({"component": component, "translate": [100, 0, 0]})).unwrap();
+ let mut app = SolveApp::new(session, Services::default());
+ act(&mut app, &Target::Component { id: component }, "Properties");
+ let (title, properties) = app.menu.props.as_ref().unwrap();
+ assert_eq!(title, "Subassembly");
+ assert_eq!(properties["body_count"], 1);
+ assert!((properties["total"]["volume_mm3"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
+ assert!((properties["total"]["center_of_mass"][0].as_f64().unwrap() - 5.0).abs() < 1e-6);
+ act(&mut app, &Target::Component { id: 0 }, "Properties");
+ assert_eq!(app.menu.props.as_ref().unwrap().1["body_count"], 2);
+}
+
#[test]
fn sketch_menu_hides_and_moves_to_another_plane() {
let mut app = sample_app();
diff --git a/crates/ui-egui/src/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs
new file mode 100644
index 00000000..88648237
--- /dev/null
+++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs
@@ -0,0 +1,63 @@
+use super::*;
+use solvecraft_engine::sketch::CurveKind;
+
+#[test]
+fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
+ for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] {
+ let mut h = crate::scenario::Harness::new();
+ h.app.ui.show_origin = true;
+ h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
+ h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]})).unwrap();
+ h.frames(2);
+ let sid = h.app.session.active_sketch.unwrap();
+ let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
+ let cursor = proj.to_screen(Vec3::ZERO).unwrap();
+ // The ordinary snap still finds the fixed origin; the corner tool must instead
+ // reach the rectangle's overlapping endpoint.
+ assert_eq!(sketch_point_at(&h.app, &proj, cursor).unwrap().1.as_deref(), Some("origin"));
+ h.step(&json!({"start":command})).unwrap();
+ h.step(&json!({"move":{"world":[0,0,0]}})).unwrap();
+ let point = h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref().unwrap();
+ assert_ne!(point, "origin", "the actual shared endpoint must be highlighted");
+ h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
+ let st = h.app.session.world_state();
+ let ss = st.sketch(sid).unwrap();
+ assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
+ assert!(ss.sketch.points[0].fixed);
+ assert_eq!(ss.sketch.curves.len(), 5, "{command} must actually modify the geometry; status: {:?}", h.app.status);
+ if command == "sketch.fillet" {
+ assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. })));
+ } else {
+ assert!(ss.sketch.curves.iter().all(|c| matches!(c.kind, CurveKind::Line { .. })));
+ }
+ let expected = if command == "sketch.fillet" { 600.0 - 25.0 * (1.0 - std::f64::consts::PI / 4.0) } else { 587.5 };
+ assert_eq!(ss.profiles.len(), 1);
+ assert!((ss.profiles[0].area - expected).abs() < 1e-6, "{command}: {}", ss.profiles[0].area);
+ h.app.run("edit.undo", json!({})).unwrap();
+ let st = h.app.session.world_state();
+ let ss = st.sketch(sid).unwrap();
+ assert_eq!(ss.sketch.curves.len(), 4);
+ assert!((ss.profiles[0].area - 600.0).abs() < 1e-6);
+ }
+}
+
+#[test]
+fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() {
+ let mut h = crate::scenario::Harness::new();
+ h.app.ui.show_origin = true;
+ h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
+ h.step(&json!({"start":"sketch.line"})).unwrap();
+ h.step(&json!({"move":{"world":[0,0,0]}})).unwrap();
+ assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("origin"));
+ h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
+ assert_eq!(h.app.tool.as_ref().unwrap().pts[0].1.as_deref(), Some("origin"));
+ h.app.tool = None;
+ h.app.run("sketch.line", json!({"points":["origin",[20,0],[20,20],[0,20],"origin"]})).unwrap();
+ h.step(&json!({"start":"sketch.fillet"})).unwrap();
+ h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
+ let st = h.app.session.world_state();
+ let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
+ assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
+ assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. })));
+ assert_eq!(ss.profiles.len(), 1);
+}
diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs
index c408ec41..77925592 100644
--- a/crates/ui-egui/src/sketch_dims.rs
+++ b/crates/ui-egui/src/sketch_dims.rs
@@ -240,6 +240,30 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0));
let lv = live(t, e, px);
let focus = std::mem::take(&mut t.dims_focus);
+ // Egui processes ordinary Tab navigation before these floating text edits are drawn.
+ // Remember the box that owned the keyboard and restore a focus within this tool after
+ // drawing, so the last dimension wraps to the first rather than reaching the panels.
+ let tab = ui.input(|input| {
+ input.raw.events.iter().find_map(|event| match event {
+ egui::Event::Key { key: egui::Key::Tab, pressed: true, modifiers, .. } if !modifiers.ctrl && !modifiers.alt && !modifiers.command => {
+ Some(modifiers.shift)
+ }
+ _ => None,
+ })
+ });
+ let source = tab.and_then(|_| {
+ ui.ctx().memory(|memory| {
+ (0..t.dims.len())
+ .find(|i| memory.had_focus_last_frame(egui::Id::new(("sc_dim", i))))
+ .or_else(|| (0..t.dims.len()).find(|i| memory.has_focus(egui::Id::new(("sc_dim", i)))))
+ })
+ });
+ if source.is_some() {
+ // Also cancel navigation on the first focused frame, before the text edit's focus
+ // filter has been installed (e.g. immediately repeated keyboard input).
+ ui.ctx().memory_mut(|memory| memory.move_focus(egui::FocusDirection::None));
+ }
+ let mut completed = false;
let mut any_focused = false;
for (i, b) in t.dims.iter_mut().enumerate() {
let Some((v, at)) = lv.get(i).copied() else { continue };
@@ -260,9 +284,19 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let fill = if b.locked { tk.accent_soft } else { tk.panel };
egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
- let r = ui.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0)).on_hover_text(b.label);
- crate::params_dialog::complete(ui, &r, &mut b.value);
- if r.changed() {
+ let r = ui
+ .add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).event_filter(egui::EventFilter {
+ tab: true,
+ horizontal_arrows: true,
+ vertical_arrows: true,
+ ..Default::default()
+ }))
+ .on_hover_text(b.label);
+ // A completion gets first use of forward Tab. Shift+Tab always moves back;
+ // the box reached by egui's generic traversal must not complete its text.
+ let changed = (source.is_none() || (source == Some(i) && tab != Some(true))) && crate::params_dialog::complete(ui, &r, &mut b.value);
+ completed |= changed && source == Some(i);
+ if r.changed() || changed {
b.locked = true;
}
if focus && i == 0 {
@@ -272,6 +306,21 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
});
});
}
+ if let Some(source) = source
+ && !completed
+ {
+ let next = if tab == Some(true) { (source + t.dims.len() - 1) % t.dims.len() } else { (source + 1) % t.dims.len() };
+ let id = egui::Id::new(("sc_dim", next));
+ if let Some(dimension) = t.dims.get(next) {
+ let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
+ state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
+ egui::text::CCursor::new(0),
+ egui::text::CCursor::new(dimension.value.chars().count()),
+ )));
+ state.store(ui.ctx(), id);
+ }
+ ui.ctx().memory_mut(|memory| memory.request_focus(id));
+ }
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let nothing = ui.ctx().memory(|m| m.focused().is_none());
(enter && (any_focused || nothing)).then_some(e)
@@ -281,3 +330,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
pub fn dimension_params(entities: &[String], ty: &str, value: &str) -> Value {
json!({"entities": entities, "type": ty, "value": value})
}
+
+#[cfg(test)]
+#[path = "sketch_dims_tests.rs"]
+mod tests;
diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs
new file mode 100644
index 00000000..75c3dfb7
--- /dev/null
+++ b/crates/ui-egui/src/sketch_dims_tests.rs
@@ -0,0 +1,128 @@
+//! Real keyboard input must stay inside the dimensions of the shape being drawn.
+
+use serde_json::json;
+
+use crate::scenario::Harness;
+
+fn drawing(command: &str) -> Harness {
+ let mut h = Harness::new();
+ h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
+ h.step(&json!({"start": command})).unwrap();
+ h.step(&json!({"click": {"world": [0, 0, 0]}})).unwrap();
+ h.step(&json!({"move": {"world": [20, 10, 0]}})).unwrap();
+ assert_focus(&h, 0);
+ h
+}
+
+fn assert_focus(h: &Harness, index: usize) {
+ let expected = egui::Id::new(("sc_dim", index));
+ assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(expected), "focus must stay in inline dimension {index}");
+}
+
+fn tab(h: &mut Harness, backwards: bool) {
+ h.step(&json!({"key": "Tab", "shift": backwards})).unwrap();
+}
+
+fn type_value(h: &mut Harness, text: &str) {
+ h.step(&json!({"key": "A", "cmd": true})).unwrap();
+ h.step(&json!({"text": text})).unwrap();
+}
+
+#[test]
+fn tabs_cycle_rectangle_dimensions_indefinitely_and_enter_commits_values() {
+ for command in ["sketch.rectangle.two_point", "sketch.rectangle.center"] {
+ let mut h = drawing(command);
+ type_value(&mut h, "30");
+ tab(&mut h, false);
+ assert_focus(&h, 1);
+ type_value(&mut h, "12");
+ for i in 0..8 {
+ tab(&mut h, false);
+ assert_focus(&h, i % 2);
+ }
+ for i in 0..8 {
+ tab(&mut h, true);
+ assert_focus(&h, i % 2);
+ }
+ // Revisited locked dimensions are selected, so another value replaces them directly.
+ h.step(&json!({"text": "14"})).unwrap();
+ tab(&mut h, true);
+ assert_focus(&h, 0);
+ h.step(&json!({"text": "32"})).unwrap();
+ let dims = &h.app.tool.as_ref().unwrap().dims;
+ assert_eq!(dims.iter().map(|d| d.value.as_str()).collect::>(), ["32", "14"]);
+ assert!(dims.iter().all(|d| d.locked));
+ h.step(&json!({"key": "Enter"})).unwrap();
+ let state = h.app.session.world_state();
+ let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
+ assert_eq!(sketch.sketch.curves.iter().filter(|curve| !curve.construction).count(), 4);
+ assert_eq!(sketch.profiles.len(), 1);
+ assert!((sketch.profiles[0].area - 448.0).abs() < 1e-6, "{command}: {}", sketch.profiles[0].area);
+ }
+}
+
+#[test]
+fn line_dimensions_wrap_both_directions_and_enter_uses_length_and_angle() {
+ let mut h = drawing("sketch.line");
+ type_value(&mut h, "10");
+ tab(&mut h, false);
+ type_value(&mut h, "45 deg");
+ for i in 0..6 {
+ tab(&mut h, false);
+ assert_focus(&h, i % 2);
+ }
+ tab(&mut h, true);
+ assert_focus(&h, 0);
+ h.step(&json!({"key": "Enter"})).unwrap();
+ let state = h.app.session.world_state();
+ let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
+ assert_eq!(sketch.sketch.curves.len(), 1);
+ let points: Vec<_> = sketch.sketch.points.iter().map(|point| point.pos).collect();
+ assert!(points.iter().any(|point| (point.x - 10.0 / 2f64.sqrt()).abs() < 1e-6 && (point.y - 10.0 / 2f64.sqrt()).abs() < 1e-6));
+}
+
+#[test]
+fn single_dimension_keeps_focus_for_tab_and_shift_tab() {
+ let mut h = drawing("sketch.circle.center");
+ type_value(&mut h, "8");
+ for backwards in [false, false, true, true, false] {
+ tab(&mut h, backwards);
+ assert_focus(&h, 0);
+ }
+ assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "8");
+ h.step(&json!({"key": "Enter"})).unwrap();
+ let state = h.app.session.world_state();
+ let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
+ assert_eq!(sketch.profiles.len(), 1);
+ assert!((sketch.profiles[0].area - std::f64::consts::PI * 16.0).abs() < 1e-6);
+}
+
+#[test]
+fn forward_tab_completes_expression_then_cycles_and_shift_tab_moves_back() {
+ let mut h = drawing("sketch.rectangle.two_point");
+ h.app.run("parameters.add", json!({"name": "width", "expression": "24 mm"})).unwrap();
+ h.frames(2);
+ type_value(&mut h, "wid");
+ tab(&mut h, false);
+ assert_focus(&h, 0);
+ assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width");
+ tab(&mut h, false);
+ assert_focus(&h, 1);
+ type_value(&mut h, "5");
+ tab(&mut h, true);
+ assert_focus(&h, 0);
+ type_value(&mut h, "wid");
+ tab(&mut h, true);
+ assert_focus(&h, 1);
+ assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "wid", "Shift+Tab must navigate rather than accepting a completion");
+ tab(&mut h, true);
+ assert_focus(&h, 0);
+ tab(&mut h, false);
+ assert_focus(&h, 0);
+ assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width");
+ h.step(&json!({"key": "Enter"})).unwrap();
+ let state = h.app.session.world_state();
+ let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
+ assert_eq!(sketch.profiles.len(), 1);
+ assert!((sketch.profiles[0].area - 120.0).abs() < 1e-6);
+}
diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs
index 39f02e8f..240b7c1f 100644
--- a/crates/ui-egui/src/sketch_tools.rs
+++ b/crates/ui-egui/src/sketch_tools.rs
@@ -458,6 +458,41 @@ fn sketch_entity(app: &SolveApp, hits: &[Hit]) -> Option<(String, bool)> {
})
}
+/// Corner tools accept endpoints shared by two editable curves. Pick among those endpoints
+/// directly so the origin, a circle centre or an overlapping loose point cannot hide a corner.
+pub(super) fn corner_point(app: &SolveApp, proj: &Proj, pos: Pos2, cmd: &str) -> Option<(Vec2, String)> {
+ let st = app.session.world_state();
+ let ss = st.sketch(active_sketch(app)?)?;
+ let sk = &ss.sketch;
+ sk.points
+ .iter()
+ .enumerate()
+ .filter_map(|(pi, point)| {
+ let distance = proj.to_screen(ss.plane.to_world(point.pos))?.distance(pos);
+ if distance >= 8.0 {
+ return None;
+ }
+ let curves: Vec<_> = sk
+ .curves
+ .iter()
+ .enumerate()
+ .filter(|(ci, c)| !sk.is_linked_curve(*ci) && c.kind.ends().is_some_and(|(a, b)| a == pi || b == pi))
+ .take(3)
+ .collect();
+ if curves.len() != 2
+ || curves.iter().any(|(_, c)| {
+ !matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. })
+ && (cmd != "sketch.fillet" || !matches!(c.kind, solvecraft_engine::sketch::CurveKind::Arc { .. }))
+ })
+ {
+ return None;
+ }
+ Some((distance, point.pos, point.id.clone()))
+ })
+ .min_by(|a, b| a.0.total_cmp(&b.0))
+ .map(|(_, at, id)| (at, id))
+}
+
fn xy(p: Vec2) -> Value {
json!([p.x, p.y])
}
@@ -624,6 +659,12 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
}
}
Mode::Entities(n) => {
+ if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") {
+ if let Some((_, id)) = corner_point(app, proj, pos, cmd) {
+ run_corner(app, cmd, &id);
+ }
+ return;
+ }
if cmd == "sketch.offset" && !tool.picks.is_empty() {
if let Some((p, _)) = at {
offset_side(app, tool, p);
@@ -640,12 +681,6 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
}
return;
}
- if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") {
- if is_point {
- run_corner(app, cmd, &id);
- }
- return;
- }
if cmd == "sketch.centerline" {
let _ = app.run(cmd, json!({"curves": [id]}));
return;
@@ -1132,3 +1167,7 @@ fn text_entry(app: &mut SolveApp, ctx: &egui::Context) {
None => TEXT.with(|t| *t.borrow_mut() = Some((at, text))),
}
}
+
+#[cfg(test)]
+#[path = "sketch_corner_ui_tests.rs"]
+mod corner_ui_tests;
diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs
index 681f41ba..174311d8 100644
--- a/crates/ui-egui/src/tools.rs
+++ b/crates/ui-egui/src/tools.rs
@@ -100,7 +100,11 @@ fn xy(p: &(Vec2, Option)) -> Value {
pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
crate::sketch3d::on_hover(app, proj, pos);
- let h = sketch_point_at(app, proj, pos);
+ let h = if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) {
+ crate::sketch_tools::corner_point(app, proj, pos, tool.cmd).map(|(at, id)| (at, Some(id)))
+ } else {
+ sketch_point_at(app, proj, pos)
+ };
if let Some(t) = app.tool.as_mut() {
t.hover = h;
}
diff --git a/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json b/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json
index 3f8cd9a2..e7aa525a 100644
--- a/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json
+++ b/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json
@@ -110,7 +110,15 @@
"file": "Untitled-Body1.dxf"
}
},
+ {
+ "widget": "Export Bend Table\u2026"
+ },
+ {
+ "expect": {
+ "file": "Untitled-Body1.csv"
+ }
+ },
{
"shot": "s08_flat"
}
-]
\ No newline at end of file
+]
diff --git a/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json
index 5f2b316e..a039605e 100644
--- a/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json
+++ b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json
@@ -78,14 +78,10 @@
},
{
"expect": {
- "sketch_world": {
- "sketch": "Sketch1",
- "point": "p1",
- "at": [
- 140,
- 0,
- 20
- ]
+ "linked_world_range": {
+ "x": [100, 140],
+ "y": [0, 30],
+ "z": [20, 20]
}
}
},
@@ -100,4 +96,4 @@
"errors": 0
}
}
-]
\ No newline at end of file
+]
diff --git a/crates/ui-egui/tests/scenarios/s60_appearance_overrides.json b/crates/ui-egui/tests/scenarios/s60_appearance_overrides.json
new file mode 100644
index 00000000..5aa97296
--- /dev/null
+++ b/crates/ui-egui/tests/scenarios/s60_appearance_overrides.json
@@ -0,0 +1,17 @@
+[
+ {"note": "Appearance from the Browser targets its body, filters library names, and removal survives OK and can be undone."},
+ {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}},
+ {"call": "engine.execute", "params": {"command": "appearance.assign", "params": {"bodies": ["Body1"], "color": "#abcdef", "appearance": "Tinted glass", "opacity": 0.25}}},
+ {"call": "ui.menu", "params": {"x": 300, "y": 300, "target": {"type": "body", "name": "Body1"}}},
+ {"call": "ui.menuPick", "params": {"item": "Appearance"}},
+ {"expect": {"dialog": "Appearance", "shown": "Remove override"}},
+ {"click": {"handle": "appearance:search"}},
+ {"text": "no-such-appearance"},
+ {"expect": {"shown": "No matching appearances"}},
+ {"shot": "appearance_library_search"},
+ {"widget": "Remove override"},
+ {"widget": "OK"},
+ {"expect": {"dialog": null, "query": {"command": "appearance.list", "path": ["bodies"], "value": {}}}},
+ {"call": "engine.execute", "params": {"command": "edit.undo", "params": {}}},
+ {"expect": {"query": {"command": "appearance.list", "path": ["bodies", "Body1", "opacity"], "value": 0.25}}}
+]
diff --git a/crates/ui-egui/tests/scenarios/s80_configuration_styles.json b/crates/ui-egui/tests/scenarios/s80_configuration_styles.json
new file mode 100644
index 00000000..e1e07416
--- /dev/null
+++ b/crates/ui-egui/tests/scenarios/s80_configuration_styles.json
@@ -0,0 +1,21 @@
+[
+ {"note":"Configure a body's appearance in the table, apply the edited active row, and render the result."},
+ {"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":20,"height":10,"body_name":"Block"}}},
+ {"call":"engine.execute","params":{"command":"material.assign","params":{"bodies":["Block"],"material":"Steel"}}},
+ {"start":"config.table"},
+ {"widget":"Add Column"},
+ {"widget":"Block (appearance)"},
+ {"expect":{"shown":"Appearance: Block"}},
+ {"widget":"Library"},
+ {"widget":"ABS - Red"},
+ {"widget":"Apply"},
+ {"shot":"configuration_red"},
+ {"expect":{"errors":0,"bodies":1,"query":{"command":"appearance.list","path":["bodies","Block","color"],"value":"#c42828"}}},
+ {"widget":"Library"},
+ {"widget":"Follow material"},
+ {"widget":"Apply"},
+ {"shot":"configuration_default"},
+ {"expect":{"errors":0,"bodies":1,"query":{"command":"appearance.list","path":["bodies","Block"],"value":null}}},
+ {"expect":{"query":{"command":"appearance.list","params":{"body":"Block"},"path":["body","appearance","name"],"value":"Steel"}}},
+ {"expect":{"query":{"command":"config.table","path":["matches"],"value":true}}}
+]
diff --git a/docs/assembly-properties.md b/docs/assembly-properties.md
new file mode 100644
index 00000000..588f5cc4
--- /dev/null
+++ b/docs/assembly-properties.md
@@ -0,0 +1,33 @@
+# Assembly physical properties
+
+The Browser's component **Properties** action measures the selected assembly placement,
+including its nested components. Select the root design to measure every instance in the
+assembly. The window shows material-aware mass, volume, surface area, center of mass and
+the world bounding box. Pending occurrence moves are included without capturing them.
+
+The same measurements are available through commands:
+
+```json
+{"command":"component.properties","params":{"component":"Bracket"}}
+{"command":"component.properties","params":{"component":"root"}}
+{"command":"component.properties","params":{"component":"Subassembly","descendants":false}}
+{"command":"occurrence.properties","params":{"occurrence":"Bracket:2"}}
+```
+
+`component.properties` defaults to the active component and measures its first placement.
+`occurrence.properties` measures the chosen instance. A nested occurrence can appear beneath
+several instances of its parent; supply `path` (the complete list of occurrence ids from the
+root) to choose one of those placements. An ambiguous occurrence without a path returns an
+error. Both commands accept `descendants: false` to measure only directly owned bodies.
+
+Results contain a `bodies` list and a `total` object. Each body's entry identifies the original
+body name, owning component and occurrence path. Physical material density is resolved from
+that original body, so copies keep their material and mass. Values use millimetres, square
+millimetres, cubic millimetres and grams; coordinates use the world frame.
+
+Mass and center of mass account for each body's density. Volume and area are summed over
+placed bodies, including overlapping bodies and repeated instances. Measurements use the
+kernel's tessellation-based body measurement. An empty component reports zero totals and
+null center of mass and bounding box. A failed body measurement returns an error instead of
+presenting an incomplete assembly total. These commands leave geometry, pending placements
+and undo history unchanged.
diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md
index 94aefac0..e837086b 100644
--- a/docs/book/src/guide/assemblies.md
+++ b/docs/book/src/guide/assemblies.md
@@ -20,3 +20,12 @@ couples two joints. Contact sets stop motion where bodies would pass through eac
volumes in red.
STEP export writes the component tree as an assembly.
+
+Right-click a component in the Browser and choose **Properties** to inspect
+material-aware mass, volume, surface area, centre of mass and world bounds. Descendants and
+repeated instances contribute to assembly totals; pending moves are included. A component
+uses its first placement; `occurrence.properties` selects a specific instance. Root Properties
+measures the complete assembly. Volumes are summed, including overlapping bodies, rather than
+measuring their union. The same workflow is available through `component.properties` and
+`occurrence.properties`; see
+[placement and measurement details](https://github.com/bherbruck/solvecraft/blob/main/docs/assembly-properties.md).
diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md
index b3da3ecb..c9d0ea70 100644
--- a/docs/book/src/guide/parameters.md
+++ b/docs/book/src/guide/parameters.md
@@ -18,4 +18,8 @@ existing geometry.
Parameters export to and import from CSV or JSON (`parameters.export`, `parameters.import`).
**Configurations** (`config.*`) keep a table of rows, each with its own parameter values and
-suppressed features, switched with `config.activate`.
+suppressed features, physical materials and body appearances, switched with `config.activate`.
+The table's Library picker selects material or appearance values. Choose **Follow material**
+to clear an appearance override. Custom looks retain their colour and opacity across
+activation, undo and save/reopen. See
+[configuration commands and examples](https://github.com/bherbruck/solvecraft/blob/main/docs/configurations.md).
diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md
index 78d5c7de..b38a8e15 100644
--- a/docs/book/src/guide/sheet-metal.md
+++ b/docs/book/src/guide/sheet-metal.md
@@ -9,7 +9,22 @@ The SHEET METAL tab builds parts from a sheet of constant thickness under a **sh
- **Unfold / Refold** (`sheet.unfold`, `sheet.refold`) for working on the flat part.
- **Convert** (`sheet.convert`) turns a solid of constant thickness into sheet metal.
- **Create Flat Pattern** (`sheet.flat_pattern`) shows the flat part with its bend lines and a
- bend table, and exports it as DXF.
+ bend table, and exports it as DXF or a bend-table CSV through the save picker.
Bends follow the rule's K-factor, so flat lengths match what a press brake will make. The
oracle checks these against parts built in Fusion.
+
+For a shop drawing or spreadsheet, `sheet.export_bend_table` returns CSV by default; choose
+`format: "json"` for structured data. It includes the sheet rule, thickness, bend angles,
+radii, allowances and flat centre-line coordinates. Lengths use millimetres and angles use
+degrees. Supply `path` to save it, or omit `path` to receive the content through the command
+response. `body` defaults to the last sheet body. Exporting does not add a timeline entry or
+consume an undo step.
+
+```json
+{"command":"sheet.export_bend_table","params":{"format":"csv","path":"bracket-bends.csv"}}
+{"command":"sheet.export_dxf","params":{"path":"bracket-flat.dxf"}}
+```
+
+DXF cutout geometry preserves lines, circles and circular arcs; other curves are sampled
+adaptively. The `OUTLINE`, `BEND_UP` and `BEND_DOWN` layers separate cuts from bend centres.
diff --git a/docs/configurations.md b/docs/configurations.md
new file mode 100644
index 00000000..43141b28
--- /dev/null
+++ b/docs/configurations.md
@@ -0,0 +1,43 @@
+# Design configurations
+
+The configuration table stores named design variants. Add parameter, feature suppression,
+physical material, and body appearance columns with `config.configure` or `config.column`.
+The desktop Configuration Table's **Add Column** menu offers the same targets. Material and
+appearance cells have a **Library** picker; their text fields also accept exact saved values.
+
+```json
+{"command":"config.configure","params":{"params":["width"],"materials":["Box1"],"appearances":["Box1"]}}
+{"command":"config.row","params":{"name":"Aluminum blue","values":{"param:width":"80 mm","material:Box1":"Aluminum","appearance:Box1":"Aluminum - Anodized Blue"}}}
+{"command":"config.activate","params":{"row":"Aluminum blue"}}
+```
+
+Use the body's actual Browser name in place of `Box1`. `config.table` returns the column keys,
+rows, active name, and `matches`: whether the current design still has the active row's values.
+Changing an assignment by hand makes `matches` false. Activating another row is undoable.
+
+Material values are names from `material.list` (case insensitive), with `Default` clearing the
+body's physical material assignment. Appearance values are names from `appearance.library`,
+an empty string or `Default` to clear the body's appearance, or a custom look object:
+
+```json
+{"command":"config.row","params":{"name":"Transparent blue","from":"Aluminum blue","values":{"appearance:Box1":{"name":"Custom blue","color":[40,90,186],"opacity":0.4}}}}
+```
+
+The table stores custom looks without losing colour or opacity. `color` contains three byte
+values, names support up to 128 Unicode characters, and `opacity` must be finite and between
+zero and one. Style values are validated when
+editing a row and again when activating a saved row. An invalid value leaves the design unchanged.
+Clearing a body appearance allows component appearance or material colour to show through;
+face appearances keep their existing priority. Physical material changes affect measured mass.
+
+`config.row` supports `from` to copy another row, `rename` to rename an existing row, and
+`delete` to remove it. Names must be unique. To remove a style column, use
+`config.column` with `material` or `appearance` set to the target body and `delete: true`.
+
+Style columns target body names. Assignments remain stored when a row suppresses the feature
+that produces that body, and become visible when it returns. Component and face appearance
+columns and conditional configuration rules are not yet supported.
+
+Designs saved with these columns use file format 7. Older designs upgrade on opening without
+changing their existing configuration values. Older SolveCraft versions must be updated to
+open newly saved designs.
diff --git a/docs/contact-sets.md b/docs/contact-sets.md
new file mode 100644
index 00000000..bc240e27
--- /dev/null
+++ b/docs/contact-sets.md
@@ -0,0 +1,34 @@
+# Contact sets and joint motion
+
+Contact sets constrain **Drive Joints**, motion-study playback and exported motion samples.
+Bodies belonging to different members of an enabled set are checked along the requested
+joint motion. A collision between clear start and end poses can therefore stop the motion,
+including slider travel through a blocker and revolute travel across one.
+
+```json
+{"command":"contact.create","params":{"occurrences":["Base:1","Carriage:1"]}}
+{"command":"joint.drive","params":{"joint":"Rail","value":30}}
+```
+
+A stopped command returns `stopped_by_contact`, identifying the occurrence pair and the
+fraction of the requested motion reached. The first detected collision interval is refined
+to leave the bodies just short of penetration, within the tessellated contact tolerance.
+Motion links and limits participate in the trajectory checks. Moving a body between
+components preserves its placement for contact checks through its body offset.
+
+The trajectory uses bounded samples of cached tessellated geometry. Spacing accounts for
+effective joint travel, angular motion, distance from joint pivots and whole-body widths
+along sampled face directions. This handles tilted thin plates while keeping curved bodies
+practical to drive. It is not exact continuous collision detection: locally thin walls in
+a larger body, unsampled face directions and very small geometric details remain limitations.
+
+When a motion or assembly exceeds the sampling, geometry or collision-work budget, the
+command returns an error and restores the document. Use smaller drive increments or a
+smaller contact set. Mesh bodies in contact sets are currently rejected for swept driving.
+An assembly already overlapping at the start retains the recovery behaviour that allows
+it to move out of that invalid placement. Disable contact to move freely.
+
+`motion.export` uses the same checks between its requested poses, including sparse exports,
+and leaves the source document unchanged. Contact checks interpolate joint values between
+those poses; they do not reconstruct intermediate motion-study keyframes omitted by the
+requested sample steps.
diff --git a/docs/parity.md b/docs/parity.md
index 02a7a78e..32d6be9e 100644
--- a/docs/parity.md
+++ b/docs/parity.md
@@ -2,9 +2,9 @@
Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth.
-**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 328 / 541 commands live (61%).** SOLID + SKETCH: 185 / 291 (64%). Registered commands: 300.
+**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 331 / 541 commands live (61%).** SOLID + SKETCH: 186 / 291 (64%). Registered commands: 307.
-Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 616 / 1175 (52%).
+Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 623 / 1175 (53%).
| Tab | Panel | Live | Total | % |
|---|---|---:|---:|---:|
@@ -12,7 +12,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| SOLID | MODIFY | 19 | 32 | 59% |
| SOLID | ASSEMBLE | 12 | 21 | 57% |
| SOLID | CONFIGURE | 2 | 3 | 67% |
-| SOLID | CONSTRUCT | 18 | 20 | 90% |
+| SOLID | CONSTRUCT | 19 | 20 | 95% |
| SOLID | INSPECT | 12 | 25 | 48% |
| SOLID | INSERT | 6 | 11 | 55% |
| SOLID | SELECT | 0 | 15 | 0% |
@@ -30,7 +30,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| SHEET METAL | MODIFY | 12 | 21 | 57% |
| SHEET METAL | ASSEMBLE | 11 | 20 | 55% |
| SHEET METAL | CONFIGURE | 2 | 3 | 67% |
-| SHEET METAL | CONSTRUCT | 18 | 20 | 90% |
+| SHEET METAL | CONSTRUCT | 19 | 20 | 95% |
| SHEET METAL | INSPECT | 12 | 25 | 48% |
| SHEET METAL | INSERT | 6 | 11 | 55% |
| SHEET METAL | SELECT | 0 | 15 | 0% |
@@ -43,7 +43,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| ASSEMBLY | MODIFY | 8 | 11 | 73% |
| ASSEMBLY | CONTACT | 2 | 2 | 100% |
| ASSEMBLY | CONFIGURE | 2 | 3 | 67% |
-| ASSEMBLY | CONSTRUCT | 18 | 20 | 90% |
+| ASSEMBLY | CONSTRUCT | 19 | 20 | 95% |
| ASSEMBLY | INSPECT | 12 | 25 | 48% |
| ASSEMBLY | SELECT | 0 | 15 | 0% |
| ASSEMBLY | POSITION | 2 | 2 | 100% |
@@ -57,7 +57,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| SURFACE | MODIFY | 17 | 26 | 65% |
| SURFACE | ASSEMBLE | 11 | 20 | 55% |
| SURFACE | CONFIGURE | 2 | 3 | 67% |
-| SURFACE | CONSTRUCT | 18 | 20 | 90% |
+| SURFACE | CONSTRUCT | 19 | 20 | 95% |
| SURFACE | INSPECT | 12 | 25 | 48% |
| SURFACE | INSERT | 6 | 11 | 55% |
| SURFACE | SELECT | 0 | 15 | 0% |
@@ -67,7 +67,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| MESH | MODIFY | 6 | 30 | 20% |
| MESH | ASSEMBLE | 12 | 21 | 57% |
| MESH | CONFIGURE | 2 | 3 | 67% |
-| MESH | CONSTRUCT | 18 | 20 | 90% |
+| MESH | CONSTRUCT | 19 | 20 | 95% |
| MESH | INSPECT | 12 | 25 | 48% |
| MESH | INSERT | 5 | 10 | 50% |
| MESH | SELECT | 0 | 10 | 0% |
@@ -77,7 +77,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| FORM | MODIFY | 5 | 31 | 16% |
| FORM | SYMMETRY | 0 | 6 | 0% |
| FORM | UTILITIES | 0 | 5 | 0% |
-| FORM | CONSTRUCT | 18 | 20 | 90% |
+| FORM | CONSTRUCT | 19 | 20 | 95% |
| FORM | INSPECT | 12 | 25 | 48% |
| FORM | INSERT | 7 | 13 | 54% |
| FORM | ASSEMBLE | 0 | 1 | 0% |
@@ -88,7 +88,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
| PLASTIC | MODIFY | 19 | 32 | 59% |
| PLASTIC | ASSEMBLE | 11 | 20 | 55% |
| PLASTIC | CONFIGURE | 2 | 3 | 67% |
-| PLASTIC | CONSTRUCT | 18 | 20 | 90% |
+| PLASTIC | CONSTRUCT | 19 | 20 | 95% |
| PLASTIC | INSPECT | 12 | 26 | 46% |
| PLASTIC | INSERT | 6 | 11 | 55% |
| PLASTIC | SELECT | 0 | 15 | 0% |
@@ -170,6 +170,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
- SOLID › CONSTRUCT › Plane at Angle
- SOLID › CONSTRUCT › Tangent Plane
- SOLID › CONSTRUCT › Midplane
+- SOLID › CONSTRUCT › Perpendicular Plane
- SOLID › CONSTRUCT › Plane Through Two Edges
- SOLID › CONSTRUCT › Plane Through Three Points
- SOLID › CONSTRUCT › Plane Along Path
@@ -333,7 +334,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
- SOLID › ASSEMBLE › As-Built Constraint
- SOLID › CONFIGURE › Create Configuration Rules
- SOLID › CONSTRUCT › User Coordinate System (UCS)
-- SOLID › CONSTRUCT › Perpendicular Plane
- SOLID › INSPECT › Validate
- SOLID › INSPECT › Component Connection Analysis
- SOLID › INSPECT › DOF Analysis
From 07932d0f42d65097f583aa69df496b59a54fd908 Mon Sep 17 00:00:00 2001
From: bherbruck
Date: Sun, 11 Oct 2026 01:28:01 -0400
Subject: [PATCH 4/9] feat: preserve modeling intent across sketches,
assemblies, and fabrication
Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands.
Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected.
---
ROADMAP.md | 35 +-
crates/doc/src/appearance.rs | 8 +-
crates/doc/src/assembly.rs | 49 +-
crates/doc/src/config.rs | 150 +++-
crates/doc/src/config_rules.rs | 646 ++++++++++++++++++
crates/doc/src/document.rs | 35 +-
crates/doc/src/eval.rs | 59 +-
crates/doc/src/eval_more.rs | 35 +-
crates/doc/src/format.rs | 68 +-
crates/doc/src/lib.rs | 2 +-
crates/doc/src/params.rs | 74 ++
crates/doc/src/sheet.rs | 518 ++++++++++----
crates/engine/src/cmd/browser.rs | 70 +-
crates/engine/src/cmd/component.rs | 233 ++++++-
crates/engine/src/cmd/component_properties.rs | 39 +-
crates/engine/src/cmd/config.rs | 281 +++++++-
crates/engine/src/cmd/delete.rs | 9 +-
crates/engine/src/cmd/edit.rs | 3 +
crates/engine/src/cmd/inspect.rs | 15 +-
crates/engine/src/cmd/occurrence_tests.rs | 421 ++++++++++++
crates/engine/src/cmd/sheet.rs | 35 +-
crates/engine/src/cmd/sheet_tests.rs | 325 +++++++++
crates/engine/src/cmd/sketch.rs | 38 +-
crates/engine/src/cmd/sketch_constraints.rs | 5 +-
crates/engine/src/cmd/sketch_dimensioned.rs | 114 ++++
.../src/cmd/sketch_dimensioned_tests.rs | 167 +++++
crates/engine/src/frames.rs | 38 +-
crates/engine/src/lib.rs | 39 +-
crates/engine/src/occurrences.rs | 216 ++++++
crates/engine/src/view.rs | 15 +-
crates/kernel/src/body.rs | 103 ++-
crates/kernel/src/body_lump_tests.rs | 224 ++++++
crates/kernel/src/lib.rs | 1 +
crates/kernel/src/ops.rs | 50 +-
crates/kernel/src/prism.rs | 306 ++++++++-
crates/kernel/src/sew.rs | 134 +++-
crates/kernel/src/shell_groups.rs | 222 ++++++
crates/sketch/src/dims.rs | 20 +
crates/sketch/src/model.rs | 77 ++-
crates/sketch/src/solver.rs | 128 +++-
crates/ui-egui/src/access.rs | 2 +-
crates/ui-egui/src/browser.rs | 230 ++++++-
crates/ui-egui/src/configuration_rules_ui.rs | 356 ++++++++++
crates/ui-egui/src/context_menu.rs | 104 ++-
crates/ui-egui/src/control.rs | 11 +
crates/ui-egui/src/delete.rs | 18 +-
crates/ui-egui/src/dialogs.rs | 2 +-
crates/ui-egui/src/dialogs_appearance.rs | 4 +-
crates/ui-egui/src/dialogs_motion.rs | 114 +++-
crates/ui-egui/src/documents.rs | 2 +
crates/ui-egui/src/lib.rs | 10 +-
crates/ui-egui/src/menu_tests.rs | 20 +
crates/ui-egui/src/occurrence_ui_tests.rs | 182 +++++
crates/ui-egui/src/sketch_dims.rs | 87 ++-
crates/ui-egui/src/sketch_tools.rs | 1 +
.../ui-egui/src/sketch_typed_intent_tests.rs | 184 +++++
crates/ui-egui/src/tools.rs | 93 ++-
crates/ui-egui/src/viewport.rs | 42 +-
.../s34_component_move_carries_sketches.json | 4 +-
.../scenarios/s61_occurrence_browser.json | 22 +
docs/book/src/guide/sheet-metal.md | 10 +
docs/configurations.md | 62 +-
docs/occurrence-workflows.md | 39 ++
docs/parity.md | 22 +-
xtask/data/fusion-ids.tsv | 1 +
65 files changed, 6145 insertions(+), 484 deletions(-)
create mode 100644 crates/doc/src/config_rules.rs
create mode 100644 crates/engine/src/cmd/occurrence_tests.rs
create mode 100644 crates/engine/src/cmd/sketch_dimensioned.rs
create mode 100644 crates/engine/src/cmd/sketch_dimensioned_tests.rs
create mode 100644 crates/engine/src/occurrences.rs
create mode 100644 crates/kernel/src/body_lump_tests.rs
create mode 100644 crates/kernel/src/shell_groups.rs
create mode 100644 crates/ui-egui/src/configuration_rules_ui.rs
create mode 100644 crates/ui-egui/src/occurrence_ui_tests.rs
create mode 100644 crates/ui-egui/src/sketch_typed_intent_tests.rs
create mode 100644 crates/ui-egui/tests/scenarios/s61_occurrence_browser.json
create mode 100644 docs/occurrence-workflows.md
diff --git a/ROADMAP.md b/ROADMAP.md
index bed9e2f7..a54854aa 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -7,9 +7,9 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Question | Answer |
|---|---|
-| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
-| Command parity (in scope) | **331 / 541 (61%)** in-scope commands; SOLID + SKETCH **186 / 291 (64%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
-| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
+| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
+| Command parity (in scope) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
+| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
@@ -46,7 +46,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| # | Milestone | Status | Hours left |
|---|---|---|---|
| M0 | Vertical slice | done | — |
-| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 |
+| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Remaining workflow repairs: exact free-form Extend intersections, associative free-form Offset, and consistent occurrence-frame 3D wire authoring | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
@@ -58,8 +58,8 @@ 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 CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: installers, signing, docs, performance | planned | 16 |
-| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Left: configuration rules and component/face style columns | in progress | 4 |
-| 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/7"`, 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 |
+| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Left: component/face style columns | in progress | 4 |
+| 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/8"`, 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
@@ -84,7 +84,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
their neighbours are planes or cylinders. Other coincident curved configurations can still
- fail. Fully internal voids are not supported. Spheres are built from six pole-free patches.
+ fail. Bounded containment classification preserves fully internal cavities and nested
+ material islands independently of shell order, with STEP round-trip and material-membership
+ regressions. Near-contact curved shells use the general boolean path rather than a
+ containment shortcut. Spheres are built from six pole-free patches.
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
@@ -117,6 +120,24 @@ headline counts in-scope tabs only and lists the deferred ones separately.
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
selecting visited values for replacement. Expression completion retains forward-Tab
priority; real keyboard regressions cover rectangles, lines and single-value circles.
+ Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
+ and expressions through save/reopen. Shape creation and its dimensions form one undo step;
+ rejected values keep the editable field focused.
+- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
+ checked occurrence paths. Active nested occurrences author planar sketches and solids in
+ their selected frame. Pending moves compose through parents; dialog cancellation restores
+ its selected move. Source materials and body/face appearances follow transformed instances.
+ Shared nested definitions remain shared, and legacy 3D wire authoring and deep Paste New
+ need further work. See [occurrence workflows](docs/occurrence-workflows.md).
+- Configuration rules: ordered typed conditions can drive parameters and suppressions or
+ restrict values. Preview explains effective values without modifying the document; applying
+ rules validates rows atomically. Length expressions retain their authoring units across
+ document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
+ Component and face style columns remain open. See [configurations](docs/configurations.md).
+- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
+ islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
+ pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
+ Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs
index f5f8b431..89fcfce7 100644
--- a/crates/doc/src/appearance.rs
+++ b/crates/doc/src/appearance.rs
@@ -130,6 +130,10 @@ pub fn material_look(material: &str) -> Option {
}
impl Document {
+ /// Shared definition appearance for a computed occurrence body.
+ pub fn model_body_look(&self, body: &crate::ModelBody) -> Option {
+ self.body_look(body.definition_name(), body.feature)
+ }
/// A body's look (without face appearances): its own, else its component's, else its
/// material's.
pub fn body_look(&self, name: &str, feature: u64) -> Option {
@@ -167,8 +171,8 @@ impl Document {
None => Vec::new(),
},
};
- for f in self.appearances.faces.iter().filter(|f| f.body == b.name) {
- let (p, look) = (f.point, f.look.clone());
+ for f in self.appearances.faces.iter().filter(|f| f.body == b.definition_name()) {
+ let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone());
// By name (every piece of a split face), else by the point.
if let Some(n) = &f.name {
let stem = crate::naming::strip_piece(n);
diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs
index 771d31a3..ece7c690 100644
--- a/crates/doc/src/assembly.rs
+++ b/crates/doc/src/assembly.rs
@@ -385,24 +385,61 @@ impl Document {
/// further instance), its component, the occurrence path, and the transform from the frame
/// its geometry is in to the world.
pub fn body_frames(&self, st: &crate::ModelState) -> Vec<(String, u64, Vec, Mat)> {
+ self.body_frames_with_source(st).into_iter().map(|(world, _, component, path, matrix)| (world, component, path, matrix)).collect()
+ }
+
+ /// Body frames with explicit definition names; callers must not infer them from labels.
+ pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec, Mat)> {
let mut out = Vec::new();
for (comp, path, m) in self.placements() {
- let primary = path == self.first_path(comp);
- let label = || -> String {
- path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::>().join("/")
- };
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
- let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
+ let name = self.world_body_name(st, &b.name, comp, &path);
let w = match self.body_offsets.get(&b.name) {
Some(o) => mat_mul(&m, o),
None => m,
};
- out.push((name, comp, path.clone(), w));
+ out.push((name, b.definition_name().to_string(), comp, path.clone(), w));
}
}
out
}
+ /// Computed placement name. Source names retain priority; ambiguous labels use full IDs.
+ /// Resolves label ambiguity with bounded dynamic counts instead of expanding all placements.
+ pub fn world_body_name(&self, st: &crate::ModelState, source: &str, component: u64, path: &[u64]) -> String {
+ if path == self.first_path(component) {
+ return source.to_string();
+ }
+ let names: Vec<&str> = path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect();
+ let label = names.join("/");
+ let candidate = format!("{source} ({label})");
+ let mut counts = std::collections::BTreeMap::from([(0, 1usize)]);
+ for name in &names {
+ let mut next = std::collections::BTreeMap::::new();
+ for o in self.occurrences.iter().filter(|o| o.name == *name) {
+ if let Some(count) = counts.get(&o.parent) {
+ let n = next.entry(o.component).or_default();
+ *n = (*n + count).min(2);
+ }
+ }
+ counts = next;
+ }
+ let ambiguous = counts.get(&component).copied().unwrap_or(0) > 1;
+ // Slash and reserved-marker names could alias concatenated labels or fallback keys.
+ let reserved =
+ self.occurrences.iter().any(|o| o.name.contains('/') || o.name.contains("@occ[")) || st.bodies.iter().any(|b| b.name.contains("@occ["));
+ if !ambiguous && !reserved && st.body(&candidate).is_none() {
+ return candidate;
+ }
+ let ids = path.iter().map(u64::to_string).collect::>().join("/");
+ let encoded = serde_json::to_string(source).unwrap_or_else(|_| source.to_string());
+ let mut key = format!("{encoded}@occ[{ids}]");
+ while st.body(&key).is_some() {
+ key.push('#');
+ }
+ key
+ }
+
/// The occurrence path of a component's first placement.
pub fn first_path(&self, component: u64) -> Vec {
let mut path = Vec::new();
diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs
index ffd465e4..97fa631c 100644
--- a/crates/doc/src/config.rs
+++ b/crates/doc/src/config.rs
@@ -6,6 +6,10 @@ use serde::{Deserialize, Serialize};
use crate::{DocError, Document, Result};
+#[path = "config_rules.rs"]
+mod rules;
+pub use rules::*;
+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Column {
@@ -32,14 +36,24 @@ pub struct ConfigTable {
pub rows: Vec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub rules: Vec,
}
pub const MAX_COLUMNS: usize = 500;
pub const MAX_ROWS: usize = 2000;
+pub fn suppression_cell(value: &str) -> Result {
+ match value.trim().to_ascii_lowercase().as_str() {
+ "true" | "1" | "yes" => Ok(true),
+ "false" | "0" | "no" => Ok(false),
+ _ => Err(DocError::Invalid("suppression must be true or false".into())),
+ }
+}
+
impl ConfigTable {
pub fn is_empty(&self) -> bool {
- self.columns.is_empty() && self.rows.is_empty()
+ self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty()
}
}
@@ -75,10 +89,46 @@ pub fn material_cell(value: &str) -> Result> {
}
impl Document {
+ /// Freeze design-unit-sensitive configuration literals when changing display units.
+ /// Explicit units and nonliteral expressions retain their source text.
+ pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
+ let feature_lengths: Vec = self
+ .features
+ .iter()
+ .flat_map(|feature| {
+ feature
+ .param_names
+ .iter()
+ .zip(feature.kind.inputs())
+ .filter(|(_, (_, _, kind))| *kind == crate::expr::Kind::Length)
+ .map(|(name, _)| name.clone())
+ })
+ .collect();
+ let lengths: std::collections::BTreeSet = feature_lengths.into_iter().chain(kept_parameters.iter().cloned()).collect();
+ for row in &mut self.configs.rows {
+ for (column, cell) in self.configs.columns.iter().zip(&mut row.cells) {
+ if matches!(column,Column::Param(name) if lengths.contains(name)) && cell.trim().parse::().is_ok() {
+ *cell = format!("{} {previous_unit}", cell.trim());
+ }
+ }
+ }
+ for rule in &mut self.configs.rules {
+ rule.preserve_length_units(&lengths, previous_unit);
+ }
+ }
+
/// A column's current value in the design.
pub fn config_current(&self, c: &Column) -> Option {
match c {
- Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e),
+ Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, kind)| {
+ // Feature literals are stored in base units, unlike newly typed bare
+ // row cells. Capture their unit explicitly before editing in an inch design.
+ if self.param(n).is_none() && kind == crate::expr::Kind::Length && self.units != "mm" && e.trim().parse::().is_ok() {
+ format!("{} mm", e.trim())
+ } else {
+ e
+ }
+ }),
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
@@ -97,27 +147,34 @@ impl Document {
/// Set the design to a row's values (all or nothing).
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
+ self.apply_configuration_with_trace(row).map(|_| ())
+ }
+
+ pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result> {
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
}
let mut next = self.clone();
+ let changes: Vec<(String, String)> = self
+ .configs
+ .columns
+ .iter()
+ .zip(&r.cells)
+ .filter_map(|(c, v)| match c {
+ Column::Param(name) => Some((name.clone(), v.clone())),
+ _ => None,
+ })
+ .collect();
+ next.change_params_atomic(&changes).map_err(|e| DocError::Invalid(format!("configuration `{row}`: {e}")))?;
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
if v.len() > 4096 {
return Err(DocError::Invalid("configuration value too long".into()));
}
match c {
- Column::Param(n) => {
- if next.config_current(c).as_deref() != Some(v.as_str()) {
- next.change_param(n, v, None, None).map_err(|e| DocError::Invalid(format!("configuration `{row}`, parameter `{n}`: {e}")))?;
- }
- }
+ Column::Param(_) => {}
Column::Suppress(id) => {
- let on = match v.trim() {
- "true" | "1" | "yes" => true,
- "false" | "0" | "no" => false,
- o => return Err(DocError::Invalid(format!("configuration `{row}`: suppression must be true or false, not `{o}`"))),
- };
+ let on = suppression_cell(v)?;
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
f.suppressed = on;
}
@@ -140,8 +197,9 @@ impl Document {
}
}
next.configs.active = Some(row.to_string());
+ let trace = next.evaluate_configuration_rules()?;
*self = next;
- Ok(())
+ Ok(trace)
}
/// The active row's values all hold in the design.
@@ -150,9 +208,24 @@ impl Document {
if r.cells.len() != self.configs.columns.len() {
return false;
}
+ if !self.configs.rules.is_empty() {
+ let mut expected = self.clone();
+ if expected.apply_configuration(&r.name).is_err() {
+ return false;
+ }
+ return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column));
+ }
self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c {
- Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()),
- Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")),
+ Column::Suppress(id) => suppression_cell(v).is_ok_and(|on| self.feature(*id).is_some_and(|f| f.suppressed == on)),
+ Column::Param(name) => {
+ let kind = self
+ .features
+ .iter()
+ .find_map(|f| f.param_names.iter().position(|n| n == name).and_then(|k| f.kind.inputs().get(k).map(|input| input.2)));
+ let expected = kind.map_or_else(|| v.trim().to_string(), |kind| self.with_design_unit(v, kind));
+ self.config_current(c)
+ .is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
+ }
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
})
@@ -163,6 +236,53 @@ impl Document {
mod tests {
use super::*;
+ #[test]
+ fn rows_use_final_graph_and_missing_targets_never_reappear() {
+ let mut d = Document::new("A");
+ d.set_param("a", "1 mm", None, None).unwrap();
+ d.set_param("b", "a", None, None).unwrap();
+ d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())];
+ d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
+ d.apply_configuration("Reordered").unwrap();
+ assert!(d.configuration_matches());
+ assert_eq!(d.param_values().0["a"].v, 2.0);
+ d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
+ let before = d.clone();
+ assert!(d.apply_configuration("Cycle").is_err());
+ assert_eq!(d, before);
+ d.params.retain(|p| p.name != "a");
+ let before = d.clone();
+ assert!(d.apply_configuration("Reordered").is_err());
+ assert_eq!(d, before);
+ }
+
+ #[test]
+ fn bulk_changes_reject_new_errors_in_unchanged_dependents() {
+ let mut d = Document::new("A");
+ d.set_param("length", "2 mm", None, None).unwrap();
+ d.set_param("twice", "length * 2", None, None).unwrap();
+ let before = d.clone();
+ assert!(d.change_params_atomic(&[("length".into(), "1 / 0".into())]).is_err());
+ assert_eq!(d, before);
+ assert!(d.change_params_atomic(&[("length".into(), "3 mm".into()), ("length".into(), "4 mm".into())]).is_err());
+ assert_eq!(d, before);
+ }
+
+ #[test]
+ fn unchanged_erroneous_configured_parameter_is_a_noop_but_changed_error_rejects() {
+ let mut d = Document::new("Existing broken design");
+ d.set_param("width", "10 mm", None, None).unwrap();
+ d.params[0].expr = "1/".into();
+ d.configs.columns = vec![Column::Param("width".into())];
+ d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] });
+ d.apply_configuration("Default").unwrap();
+ assert!(d.configuration_matches());
+ d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] });
+ let before = d.clone();
+ assert!(d.apply_configuration("Different error").is_err());
+ assert_eq!(d, before);
+ }
+
#[test]
fn rows_switch_parameters() {
let mut d = Document::new("A");
diff --git a/crates/doc/src/config_rules.rs b/crates/doc/src/config_rules.rs
new file mode 100644
index 00000000..f186fbf4
--- /dev/null
+++ b/crates/doc/src/config_rules.rs
@@ -0,0 +1,646 @@
+//! Bounded typed configuration rules. The saved row remains the editable source;
+//! ordered rules derive an effective design without overwriting its cells.
+use super::Column;
+use crate::{DocError, Document, Result, expr};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::{BTreeMap, BTreeSet};
+
+pub const MAX_RULES: usize = 128;
+const MAX_NODES: usize = 64;
+const MAX_DEPTH: usize = 8;
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ConfigRule {
+ pub id: String,
+ pub name: String,
+ #[serde(default = "enabled")]
+ pub enabled: bool,
+ /// Unit context for feature-length expressions, fixed when authoring a rule.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub length_unit: Option,
+ pub condition: RuleCondition,
+ pub effect: RuleEffect,
+}
+fn enabled() -> bool {
+ true
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
+pub enum RuleCondition {
+ Always,
+ All { conditions: Vec },
+ Any { conditions: Vec },
+ Not { condition: Box },
+ Parameter { parameter: String, operator: RuleOperator, expression: String },
+ Suppression { feature: u64, value: bool },
+}
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RuleOperator {
+ Eq,
+ Ne,
+ Lt,
+ Le,
+ Gt,
+ Ge,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
+pub enum RuleEffect {
+ DriveParameter { parameter: String, expression: String },
+ DriveSuppression { feature: u64, value: bool },
+ ParameterRange { parameter: String, minimum: String, maximum: String },
+ AllowParameterValues { parameter: String, expressions: Vec },
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct RuleTrace {
+ pub id: String,
+ pub name: String,
+ pub matched: bool,
+ pub effect: RuleEffect,
+}
+
+/// Check saved rule structure before recursive serde decoding. Absence is backward
+/// compatible; malformed present data is refused. Runtime evaluation checks it again.
+pub fn validate_rules_json(document: &Value) -> Result<()> {
+ let Some(rules) = document.get("configs").and_then(|configs| configs.get("rules")) else { return Ok(()) };
+ let rules = rules.as_array().ok_or_else(|| invalid("rules must be an array"))?;
+ if rules.len() > MAX_RULES {
+ return Err(invalid("too many rules"));
+ }
+ for rule in rules {
+ let rule = rule.as_object().ok_or_else(|| invalid("rule must be an object"))?;
+ for (key, limit) in [("id", 64), ("name", 128)] {
+ let value = rule.get(key).and_then(Value::as_str).ok_or_else(|| invalid("rule id and name are required"))?;
+ if value.trim().is_empty() || value.chars().count() > limit {
+ return Err(invalid("rule id or name is empty or too long"));
+ }
+ }
+ if let Some(unit) = rule.get("length_unit") {
+ let unit =
+ unit.as_str().filter(|unit| matches!(*unit, "mm" | "cm" | "m" | "in" | "ft")).ok_or_else(|| invalid("invalid rule length unit"))?;
+ let _ = unit;
+ }
+ let mut pending = vec![(rule.get("condition").ok_or_else(|| invalid("condition required"))?, 1usize)];
+ let mut count = 0;
+ while let Some((condition, depth)) = pending.pop() {
+ count += 1;
+ if depth > MAX_DEPTH || count > MAX_NODES {
+ return Err(invalid("condition nesting or node limit exceeded"));
+ }
+ let kind = condition.get("type").and_then(Value::as_str).ok_or_else(|| invalid("condition type required"))?;
+ match kind {
+ "all" | "any" => {
+ let children = condition.get("conditions").and_then(Value::as_array).ok_or_else(|| invalid("conditions must be an array"))?;
+ if children.is_empty() || children.len() > MAX_NODES {
+ return Err(invalid("condition group is empty or too large"));
+ }
+ pending.extend(children.iter().map(|child| (child, depth + 1)));
+ }
+ "not" => pending.push((condition.get("condition").ok_or_else(|| invalid("nested condition required"))?, depth + 1)),
+ "parameter" => {
+ check_parameter(condition.get("parameter"))?;
+ check_expression(condition.get("expression"))?;
+ }
+ "always" | "suppression" => {}
+ _ => return Err(invalid("unknown condition type")),
+ }
+ }
+ let effect = rule.get("effect").ok_or_else(|| invalid("effect required"))?;
+ if effect.get("type").and_then(Value::as_str) != Some("drive_suppression") {
+ check_parameter(effect.get("parameter"))?;
+ }
+ match effect.get("type").and_then(Value::as_str) {
+ Some("drive_parameter") => check_expression(effect.get("expression"))?,
+ Some("parameter_range") => {
+ check_expression(effect.get("minimum"))?;
+ check_expression(effect.get("maximum"))?;
+ }
+ Some("allow_parameter_values") => {
+ let expressions = effect.get("expressions").and_then(Value::as_array).ok_or_else(|| invalid("allowed values must be an array"))?;
+ if expressions.is_empty() || expressions.len() > 128 {
+ return Err(invalid("allowed values are empty or too many"));
+ }
+ for expression in expressions {
+ check_expression(Some(expression))?;
+ }
+ }
+ Some("drive_suppression") => {}
+ _ => return Err(invalid("unknown effect type")),
+ }
+ }
+ Ok(())
+}
+fn check_expression(value: Option<&Value>) -> Result<()> {
+ let text = value.and_then(Value::as_str).ok_or_else(|| invalid("expression must be text"))?;
+ if text.trim().is_empty() || text.len() > 4096 {
+ return Err(invalid("expression is empty or too long"));
+ }
+ Ok(())
+}
+fn check_parameter(value: Option<&Value>) -> Result<()> {
+ let name = value.and_then(Value::as_str).ok_or_else(|| invalid("parameter name required"))?;
+ if name.is_empty() || name.len() > 64 || Document::param_name_problem(name).is_some() {
+ return Err(invalid("invalid parameter name"));
+ }
+ Ok(())
+}
+fn invalid(message: &str) -> DocError {
+ DocError::Invalid(format!("configuration rules: {message}"))
+}
+
+fn operands(
+ doc: &Document,
+ values: &BTreeMap,
+ parameter: &str,
+ expression: &str,
+ length_unit: Option<&str>,
+) -> Result<(f64, f64)> {
+ let lhs = values.get(parameter).copied().ok_or_else(|| invalid(&format!("parameter `{parameter}` is missing or invalid")))?;
+ let unit = doc
+ .param(parameter)
+ .map(|p| p.unit.as_str())
+ .or_else(|| lhs.kind().map(|kind| if kind == expr::Kind::Length { length_unit.unwrap_or(doc.units.as_str()) } else { kind.default_unit() }))
+ .unwrap_or("");
+ let defaults = expr::Defaults::for_unit(unit, expr::unit_info(&doc.units).map(|(_, scale)| scale).unwrap_or(1.0));
+ let rhs = expr::eval_with_defaults(
+ expression,
+ &|name| values.get(name).copied().ok_or_else(|| invalid(&format!("unknown parameter `{name}`"))),
+ defaults,
+ )?;
+ let kind = lhs.kind().ok_or_else(|| invalid("comparison requires length, angle or unitless values"))?;
+ let right = rhs.to_kind_in(kind, defaults)?;
+ if !lhs.v.is_finite() || !right.is_finite() {
+ return Err(invalid("comparison requires finite values"));
+ }
+ Ok((lhs.v, right))
+}
+fn close(a: f64, b: f64) -> bool {
+ (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
+}
+fn validate_drive_graph(graph: &BTreeMap>, parameter: &str, expression: &str) -> Result<()> {
+ let mut pending = expr::references(expression);
+ let mut visited = BTreeSet::new();
+ while let Some(name) = pending.pop() {
+ if name == parameter {
+ return Err(invalid(&format!("drive for `{parameter}` creates a parameter cycle")));
+ }
+ if visited.insert(name.clone())
+ && let Some(references) = graph.get(&name)
+ {
+ pending.extend(references.iter().cloned());
+ }
+ }
+ Ok(())
+}
+fn condition(doc: &Document, values: &BTreeMap, value: &RuleCondition, length_unit: Option<&str>) -> Result {
+ match value {
+ RuleCondition::Always => Ok(true),
+ RuleCondition::All { conditions } => {
+ for c in conditions {
+ if !condition(doc, values, c, length_unit)? {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+ }
+ RuleCondition::Any { conditions } => {
+ for c in conditions {
+ if condition(doc, values, c, length_unit)? {
+ return Ok(true);
+ }
+ }
+ Ok(false)
+ }
+ RuleCondition::Not { condition: c } => Ok(!condition(doc, values, c, length_unit)?),
+ RuleCondition::Suppression { feature, value } => {
+ Ok(doc.feature(*feature).ok_or_else(|| invalid("condition feature is gone"))?.suppressed == *value)
+ }
+ RuleCondition::Parameter { parameter, operator, expression } => {
+ let (a, b) = operands(doc, values, parameter, expression, length_unit)?;
+ Ok(match operator {
+ RuleOperator::Eq => close(a, b),
+ RuleOperator::Ne => !close(a, b),
+ RuleOperator::Lt => a < b,
+ RuleOperator::Le => a <= b || close(a, b),
+ RuleOperator::Gt => a > b,
+ RuleOperator::Ge => a >= b || close(a, b),
+ })
+ }
+ }
+}
+
+impl Document {
+ /// Apply ordered drives, then enforce all matched restrictions on the final state.
+ /// Running on a scratch document makes a failing rule fully atomic for callers.
+ pub(super) fn evaluate_configuration_rules(&mut self) -> Result> {
+ validate_rules_json(&serde_json::json!({"configs":{"rules":self.configs.rules}}))?;
+ let mut ids = BTreeSet::new();
+ let mut trace = Vec::new();
+ let mut restrictions = Vec::new();
+ let (mut values, _) = self.param_values();
+ let parameter_names: BTreeSet = self.all_param_exprs().into_iter().map(|(name, _, _)| name).collect();
+ let mut dependency_graph: BTreeMap> =
+ self.all_param_exprs().into_iter().map(|(name, expression, _)| (name, expr::references(&expression))).collect();
+ for rule in self.configs.rules.clone() {
+ if !ids.insert(rule.id.clone()) {
+ return Err(invalid("duplicate rule id"));
+ }
+ if let Some(name) = rule.parameter_references().iter().find(|name| !parameter_names.contains(*name)) {
+ return Err(invalid(&format!("rule `{}` references missing parameter `{name}`", rule.name)));
+ }
+ // Targets must remain configured, including disabled rules, so dormant rules
+ // cannot silently become invalid and surprise a subsequent activation.
+ let target = match &rule.effect {
+ RuleEffect::DriveParameter { parameter, .. }
+ | RuleEffect::ParameterRange { parameter, .. }
+ | RuleEffect::AllowParameterValues { parameter, .. } => Column::Param(parameter.clone()),
+ RuleEffect::DriveSuppression { feature, .. } => Column::Suppress(*feature),
+ };
+ if !self.configs.columns.contains(&target) {
+ return Err(invalid(&format!("rule `{}` target is not configured", rule.name)));
+ }
+ let matched = rule.enabled
+ && condition(self, &values, &rule.condition, rule.length_unit.as_deref())
+ .map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
+ trace.push(RuleTrace { id: rule.id, name: rule.name.clone(), matched, effect: rule.effect.clone() });
+ if !matched {
+ continue;
+ }
+ match rule.effect {
+ RuleEffect::DriveParameter { parameter, mut expression } => {
+ validate_drive_graph(&dependency_graph, ¶meter, &expression)?;
+ dependency_graph.insert(parameter.clone(), expr::references(&expression));
+ if self.param(¶meter).is_none()
+ && rule.length_unit.as_deref().is_some_and(|unit| unit != self.units)
+ && values.get(¶meter).is_some_and(|value| value.kind() == Some(expr::Kind::Length))
+ {
+ let (_, value) = operands(self, &values, ¶meter, &expression, rule.length_unit.as_deref())?;
+ expression = format!("{value} mm");
+ }
+ self.change_params_atomic(&[(parameter, expression)]).map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
+ values = self.param_values().0;
+ }
+ RuleEffect::DriveSuppression { feature, value } => {
+ self.feature_mut(feature).ok_or_else(|| invalid("rule feature is gone"))?.suppressed = value
+ }
+ effect => restrictions.push((rule.name, effect, rule.length_unit)),
+ }
+ }
+ for (name, effect, length_unit) in restrictions {
+ let valid = match effect {
+ RuleEffect::ParameterRange { parameter, minimum, maximum } => {
+ let (value, min) = operands(self, &values, ¶meter, &minimum, length_unit.as_deref())?;
+ let (_, max) = operands(self, &values, ¶meter, &maximum, length_unit.as_deref())?;
+ min <= max && (value >= min || close(value, min)) && (value <= max || close(value, max))
+ }
+ RuleEffect::AllowParameterValues { parameter, expressions } => {
+ let mut valid = false;
+ for expression in expressions {
+ let (a, b) = operands(self, &values, ¶meter, &expression, length_unit.as_deref())?;
+ valid |= close(a, b);
+ }
+ valid
+ }
+ _ => true,
+ };
+ if !valid {
+ return Err(invalid(&format!("rule `{name}` restricts the final value")));
+ }
+ }
+ Ok(trace)
+ }
+}
+
+impl ConfigRule {
+ pub(super) fn preserve_length_units(&mut self, parameters: &BTreeSet, unit: &str) {
+ if self.length_unit.is_none() {
+ self.length_unit = Some(unit.to_string());
+ }
+ fn annotate(parameter: &str, expression: &mut String, parameters: &BTreeSet, unit: &str) {
+ if parameters.contains(parameter) && expression.trim().parse::().is_ok() {
+ *expression = format!("{} {unit}", expression.trim());
+ }
+ }
+ fn walk(condition: &mut RuleCondition, parameters: &BTreeSet, unit: &str) {
+ match condition {
+ RuleCondition::Parameter { parameter, expression, .. } => annotate(parameter, expression, parameters, unit),
+ RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
+ for condition in conditions {
+ walk(condition, parameters, unit);
+ }
+ }
+ RuleCondition::Not { condition } => walk(condition, parameters, unit),
+ _ => {}
+ }
+ }
+ walk(&mut self.condition, parameters, unit);
+ match &mut self.effect {
+ RuleEffect::DriveParameter { parameter, expression } => annotate(parameter, expression, parameters, unit),
+ RuleEffect::ParameterRange { parameter, minimum, maximum } => {
+ annotate(parameter, minimum, parameters, unit);
+ annotate(parameter, maximum, parameters, unit);
+ }
+ RuleEffect::AllowParameterValues { parameter, expressions } => {
+ for expression in expressions {
+ annotate(parameter, expression, parameters, unit);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// Parameter references include targets and condition/effect expressions.
+ pub fn parameter_references(&self) -> Vec {
+ let mut copy = self.clone();
+ let mut references = Vec::new();
+ copy.visit_parameters(&mut |text, expression| {
+ if expression {
+ references.extend(expr::references(text));
+ } else {
+ references.push(text.clone());
+ }
+ });
+ references
+ }
+ pub fn rename_parameter(&mut self, old: &str, new: &str) {
+ self.visit_parameters(&mut |text, expression| {
+ if expression {
+ *text = expr::rename_reference(text, old, new);
+ } else if text == old {
+ *text = new.to_string();
+ }
+ });
+ }
+ fn visit_parameters(&mut self, visit: &mut impl FnMut(&mut String, bool)) {
+ fn walk(c: &mut RuleCondition, visit: &mut impl FnMut(&mut String, bool)) {
+ match c {
+ RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
+ for c in conditions {
+ walk(c, visit);
+ }
+ }
+ RuleCondition::Not { condition } => walk(condition, visit),
+ RuleCondition::Parameter { parameter, expression, .. } => {
+ visit(parameter, false);
+ visit(expression, true);
+ }
+ _ => {}
+ }
+ }
+ walk(&mut self.condition, visit);
+ match &mut self.effect {
+ RuleEffect::DriveParameter { parameter, expression } => {
+ visit(parameter, false);
+ visit(expression, true);
+ }
+ RuleEffect::ParameterRange { parameter, minimum, maximum } => {
+ visit(parameter, false);
+ visit(minimum, true);
+ visit(maximum, true);
+ }
+ RuleEffect::AllowParameterValues { parameter, expressions } => {
+ visit(parameter, false);
+ for e in expressions {
+ visit(e, true);
+ }
+ }
+ _ => {}
+ }
+ }
+ pub fn references_features(&self, ids: &[u64]) -> bool {
+ fn condition(c: &RuleCondition, ids: &[u64]) -> bool {
+ match c {
+ RuleCondition::Suppression { feature, .. } => ids.contains(feature),
+ RuleCondition::All { conditions } | RuleCondition::Any { conditions } => conditions.iter().any(|c| condition(c, ids)),
+ RuleCondition::Not { condition: c } => condition(c, ids),
+ _ => false,
+ }
+ }
+ condition(&self.condition, ids) || matches!(&self.effect,RuleEffect::DriveSuppression {feature,..} if ids.contains(feature))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::ConfigRow;
+ fn design() -> Document {
+ let mut doc = Document::new("Rules");
+ doc.set_param("width", "10 mm", None, None).unwrap();
+ doc.set_param("depth", "5 mm", None, None).unwrap();
+ doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
+ doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
+ doc
+ }
+ fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule {
+ ConfigRule { id: id.into(), name: id.into(), enabled: true, length_unit: None, condition, effect }
+ }
+ #[test]
+ fn ordered_rules_derive_values_without_overwriting_rows() {
+ let mut doc = design();
+ doc.configs.rules = vec![
+ rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "1 in".into() }),
+ rule(
+ "second",
+ RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Gt, expression: "20 mm".into() },
+ RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
+ ),
+ ];
+ let source = doc.configs.rows.clone();
+ let trace = doc.apply_configuration_with_trace("Default").unwrap();
+ assert!(trace.iter().all(|entry| entry.matched));
+ assert!((doc.param_values().0["depth"].v - 12.7).abs() < 1e-9);
+ assert_eq!(doc.configs.rows, source);
+ assert!(doc.configuration_matches());
+ doc.configs.rules.swap(0, 1);
+ let trace = doc.apply_configuration_with_trace("Default").unwrap();
+ assert!(!trace[0].matched);
+ assert_eq!(doc.param_values().0["depth"].v, 5.0);
+ }
+ #[test]
+ fn restrictions_intersect_and_fail_atomically_with_named_rule() {
+ let mut doc = design();
+ doc.configs.rules = vec![
+ rule(
+ "range",
+ RuleCondition::Always,
+ RuleEffect::ParameterRange { parameter: "width".into(), minimum: "5 mm".into(), maximum: "1 in".into() },
+ ),
+ rule(
+ "choices",
+ RuleCondition::Always,
+ RuleEffect::AllowParameterValues { parameter: "width".into(), expressions: vec!["1 cm".into(), "20 mm".into()] },
+ ),
+ ];
+ doc.apply_configuration("Default").unwrap();
+ doc.configs.rows[0].cells[0] = "15 mm".into();
+ let before = doc.clone();
+ assert!(doc.apply_configuration("Default").unwrap_err().to_string().contains("choices"));
+ assert_eq!(doc, before);
+ doc.configs.rules[0].effect = RuleEffect::ParameterRange { parameter: "width".into(), minimum: "1 deg".into(), maximum: "90 deg".into() };
+ assert!(doc.apply_configuration("Default").is_err());
+ }
+ #[test]
+ fn rule_references_follow_rename_and_prevent_delete() {
+ let mut doc = design();
+ doc.configs.rules.push(rule(
+ "derive",
+ RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Ge, expression: "depth".into() },
+ RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
+ ));
+ assert!(doc.remove_param("width").is_err());
+ doc.rename_param("width", "span").unwrap();
+ let refs = doc.configs.rules[0].parameter_references();
+ assert!(refs.contains(&"span".into()));
+ assert!(!refs.contains(&"width".into()));
+ doc.apply_configuration("Default").unwrap();
+ assert_eq!(doc.param_values().0["depth"].v, 5.0);
+ }
+ #[test]
+ fn angle_and_unitless_comparisons_keep_their_dimensions() {
+ let mut doc = design();
+ doc.set_param("angle", "45 deg", Some("deg"), None).unwrap();
+ doc.set_param("count", "1", Some(""), None).unwrap();
+ doc.configs.columns.push(Column::Param("count".into()));
+ doc.configs.rows[0].cells.push("1".into());
+ doc.configs.rules.push(rule(
+ "angle-drive",
+ RuleCondition::Parameter { parameter: "angle".into(), operator: RuleOperator::Gt, expression: "1".into() },
+ RuleEffect::DriveParameter { parameter: "count".into(), expression: "2".into() },
+ ));
+ let trace = doc.apply_configuration_with_trace("Default").unwrap();
+ assert!(trace[0].matched);
+ assert_eq!(doc.param_values().0["count"].v, 2.0);
+ doc.configs.rules[0].condition =
+ RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "1 deg".into() };
+ let before = doc.clone();
+ assert!(doc.apply_configuration("Default").is_err());
+ assert_eq!(doc, before);
+ }
+
+ #[test]
+ fn saved_rule_budgets_are_checked_before_deserialization() {
+ let valid = serde_json::to_value(rule(
+ "r",
+ RuleCondition::Always,
+ RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
+ ))
+ .unwrap();
+ assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[valid.clone()]}})).is_ok());
+ assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":null}})).is_err());
+ assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();129]}})).is_err());
+ let mut condition = serde_json::json!({"type":"always"});
+ for _ in 0..8 {
+ condition = serde_json::json!({"type":"not","condition":condition});
+ }
+ let mut deep = valid.clone();
+ deep["condition"] = condition;
+ assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[deep]}})).is_err());
+ let mut wide = valid.clone();
+ wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
+ assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[wide]}})).is_err());
+ let mut long = valid.clone();
+ long["effect"]["expression"] = serde_json::json!("a".repeat(4097));
+ assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[long]}})).is_err());
+ let mut malformed = valid;
+ malformed["condition"] = serde_json::json!({"type":"any","conditions":{}});
+ assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[malformed]}})).is_err());
+ }
+
+ #[test]
+ fn compound_conditions_short_circuit_guarded_numeric_expressions() {
+ let mut doc = design();
+ doc.set_param("count", "0", Some(""), None).unwrap();
+ let guard = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "0".into() };
+ let invalid_when_zero = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "1 / count".into() };
+ doc.configs.rules = vec![rule(
+ "guarded",
+ RuleCondition::All { conditions: vec![guard, invalid_when_zero.clone()] },
+ RuleEffect::DriveParameter { parameter: "width".into(), expression: "20 mm".into() },
+ )];
+ assert!(!doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
+ assert_eq!(doc.param_values().0["width"].v, 10.0);
+ doc.configs.rules[0].condition = RuleCondition::Any {
+ conditions: vec![
+ RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "0".into() },
+ invalid_when_zero,
+ ],
+ };
+ assert!(doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
+ assert_eq!(doc.param_values().0["width"].v, 20.0);
+ }
+
+ #[test]
+ fn saved_documents_reject_malformed_deep_and_oversized_rules_before_typed_decode() {
+ let valid = serde_json::to_value(rule(
+ "r",
+ RuleCondition::Always,
+ RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
+ ))
+ .unwrap();
+ let mut deep = valid.clone();
+ let mut condition = serde_json::json!({"type":"always"});
+ for _ in 0..8 {
+ condition = serde_json::json!({"type":"not","condition":condition});
+ }
+ deep["condition"] = condition;
+ let mut wide = valid.clone();
+ wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
+ let mut invalid_context = valid.clone();
+ invalid_context["length_unit"] = serde_json::json!("mile".repeat(1000));
+ for invalid in [
+ Value::Null,
+ serde_json::json!([deep]),
+ serde_json::json!([wide]),
+ serde_json::json!(vec![valid; 129]),
+ serde_json::json!([invalid_context]),
+ ] {
+ let mut saved = serde_json::to_value(design()).unwrap();
+ saved["configs"]["rules"] = invalid;
+ let error = crate::format::read(&saved.to_string()).unwrap_err().to_string();
+ assert!(error.contains("configuration rules"), "saved boundary must report bounded rule validation: {error}");
+ }
+ }
+
+ #[test]
+ fn cross_unit_effective_literals_keep_declared_drive_cycle_checks() {
+ let mut doc = Document::new("Cross-unit drive graph");
+ doc.add_feature(
+ crate::FeatureKind::Box {
+ corner: ["0".into(), "0".into(), "0".into()],
+ length: "10 mm".into(),
+ width: "5 mm".into(),
+ height: "10 mm".into(),
+ operation: crate::Operation::NewBody,
+ },
+ None,
+ )
+ .unwrap();
+ let width = doc.features[0].param_names[0].clone();
+ let depth = doc.features[0].param_names[1].clone();
+ doc.rename_param(&width, "width").unwrap();
+ doc.rename_param(&depth, "depth").unwrap();
+ doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
+ doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
+ doc.units = "in".into();
+ let mut first =
+ rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() });
+ first.length_unit = Some("mm".into());
+ let mut second =
+ rule("second", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width + 1".into() });
+ second.length_unit = Some("mm".into());
+ doc.configs.rules = vec![first];
+ doc.apply_configuration("Default").unwrap();
+ assert_eq!(doc.config_current(&Column::Param("width".into())).as_deref(), Some("6 mm"));
+ doc.configs.rules.push(second);
+ let before = doc.clone();
+ assert!(doc.apply_configuration("Default").is_err());
+ assert_eq!(doc, before);
+ }
+}
diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs
index 0fce5008..f9085052 100644
--- a/crates/doc/src/document.rs
+++ b/crates/doc/src/document.rs
@@ -1586,7 +1586,7 @@ impl Document {
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
- self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
+ self.model_body_look(b).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
}
/// The model with each body carrying the colour, opacity and face colours it is shown with
@@ -1595,7 +1595,7 @@ impl Document {
let mut st = state.clone();
for b in &mut st.bodies {
let c = self.body_color(b);
- let look = self.body_look(&b.name, b.feature);
+ let look = self.model_body_look(b);
let faces: Vec = self
.face_colors(b)
.into_iter()
@@ -1739,9 +1739,22 @@ impl Document {
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
let mut gone = vec![id];
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
+ let gone_params: Vec = self.features.iter().filter(|f| gone.contains(&f.id)).flat_map(|f| f.param_names.clone()).collect();
+ self.configs
+ .rules
+ .retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
self.features.retain(|f| !gone.contains(&f.id));
// Configuration columns of deleted features go with them.
- let keep: Vec = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
+ let keep: Vec = self
+ .configs
+ .columns
+ .iter()
+ .map(|c| match c {
+ crate::config::Column::Suppress(id) => !gone.contains(id),
+ crate::config::Column::Param(name) => !gone_params.contains(name),
+ _ => true,
+ })
+ .collect();
if keep.contains(&false) {
let mut k = keep.iter();
self.configs.columns.retain(|_| k.next().copied().unwrap_or(true));
@@ -1768,6 +1781,19 @@ impl Document {
fn referenced_names(&self) -> Vec {
let mut used: Vec = Vec::new();
+ for rule in &self.configs.rules {
+ used.extend(rule.parameter_references());
+ }
+ for (index, column) in self.configs.columns.iter().enumerate() {
+ if let crate::config::Column::Param(name) = column {
+ used.push(name.clone());
+ for row in &self.configs.rows {
+ if let Some(value) = row.cells.get(index) {
+ used.extend(expr::references(value));
+ }
+ }
+ }
+ }
for f in &self.features {
for e in f.kind.expressions() {
used.extend(expr::references(e));
@@ -1960,6 +1986,9 @@ impl Document {
c.kind.set_value(v);
}
}
+ for c in &sk.constraints {
+ sk.validate(&c.kind)?;
+ }
Ok(())
}
}
diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs
index 5dba61bb..bfd25e24 100644
--- a/crates/doc/src/eval.rs
+++ b/crates/doc/src/eval.rs
@@ -16,6 +16,10 @@ use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operati
#[derive(Clone, Debug)]
pub struct ModelBody {
pub name: String,
+ /// Definition identity for a computed placement; never parsed from its display name.
+ pub source_name: Option,
+ /// Definition geometry coordinates to this computed body's frame.
+ pub source_transform: crate::Mat,
pub body: Body,
/// Feature that created it.
pub feature: u64,
@@ -35,7 +39,21 @@ pub struct ModelBody {
impl ModelBody {
pub fn new(name: String, body: Body, feature: u64) -> Self {
- ModelBody { name, body, feature, mesh: Arc::new(OnceLock::new()), key: 0, names: Default::default(), component: 0, imported: None }
+ ModelBody {
+ name,
+ source_name: None,
+ source_transform: crate::IDENTITY,
+ body,
+ feature,
+ mesh: Arc::new(OnceLock::new()),
+ key: 0,
+ names: Default::default(),
+ component: 0,
+ imported: None,
+ }
+ }
+ pub fn definition_name(&self) -> &str {
+ self.source_name.as_deref().unwrap_or(&self.name)
}
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
pub fn mesh(&self) -> Arc {
@@ -807,6 +825,8 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation
}
Operation::Cut | Operation::Intersect => {
let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect };
+ let affected: std::collections::BTreeSet =
+ target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
let mut remove = Vec::new();
for &ti in &target_idx {
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.body.clone())) else { continue };
@@ -834,6 +854,10 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)").
let mut split: Vec = Vec::new();
for mb in std::mem::take(&mut state.bodies) {
+ if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
+ split.push(mb);
+ continue;
+ }
let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]);
if pieces.len() < 2 {
split.push(mb);
@@ -2370,11 +2394,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st:
if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let handled = more::sheet_cut(f, st, &tools, targets)?;
if !handled.is_empty() {
+ let effective = if targets.is_empty() { &f.participants } else { targets };
+ let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
let rest: Vec = st
.bodies
.iter()
+ .filter(|b| {
+ !handled.contains(&b.name)
+ && (all
+ || if effective.is_empty() {
+ b.component == f.component || b.feature == f.id
+ } else {
+ effective.contains(&b.name)
+ })
+ })
.map(|b| b.name.clone())
- .filter(|n| !handled.contains(n) && (targets.is_empty() || targets.contains(n)))
.collect();
if rest.is_empty() {
return Ok(());
@@ -2873,14 +2907,19 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
/// face finds the face where it is shown and projects its edges there. The model itself when
/// every placement agrees (the usual case).
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
+ state_in_frame_at(doc, st, comp, doc.component_transform(comp))
+}
+
+/// As `state_in_frame`, using the exact active placement for its shared source geometry.
+pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, frame: Mat) -> std::borrow::Cow<'a, ModelState> {
use std::borrow::Cow;
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
return Cow::Borrowed(st);
}
- let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) };
+ let Some(inv) = crate::mat_inverse(&frame) else { return Cow::Borrowed(st) };
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
let rel = |c: u64, body: Option<&str>| {
- let m = crate::mat_mul(&inv, &doc.component_transform(c));
+ let m = crate::mat_mul(&inv, &if c == comp { frame } else { doc.component_transform(c) });
match body.and_then(|b| doc.body_offsets.get(b)) {
Some(o) => crate::mat_mul(&m, o),
None => m,
@@ -2900,6 +2939,9 @@ pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std:
let names = crate::naming::face_names(b);
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
nb.component = b.component;
+ nb.source_name = b.source_name.clone();
+ nb.source_transform = crate::mat_mul(m, &b.source_transform);
+ nb.imported = b.imported.clone();
nb.names = crate::naming::NameCell::known(names);
*b = nb;
}
@@ -2938,17 +2980,18 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
let mut out = ModelState { body_counter: st.body_counter, ..Default::default() };
for (comp, path, m) in doc.placements() {
let primary = path == first_path(comp);
- let label = || -> String {
- path.iter().filter_map(|id| doc.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::>().join("/")
- };
let ident = crate::is_identity(&m);
for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) {
- let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
+ let name = doc.world_body_name(st, &b.name, comp, &path);
// A body moved in from another component keeps its place (its offset).
let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
let mut placed = ModelBody::new(name, body, b.feature);
+ placed.source_name = Some(b.definition_name().to_string());
+ placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
+ placed.component = comp;
+ placed.imported = b.imported.clone();
// Placement preserves topology: face ownership remains that of the
// source model, including modifiers on component instances.
placed.names = crate::naming::NameCell::known(crate::naming::face_names(b));
diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs
index 84b30e53..4c7a0ec8 100644
--- a/crates/doc/src/eval_more.rs
+++ b/crates/doc/src/eval_more.rs
@@ -497,17 +497,22 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
}
}
let mut handled = Vec::new();
+ let mut overlap_work = crate::sheet::FlatWork::new();
+ let targets = if targets.is_empty() { &f.participants } else { targets };
+ let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
for i in 0..st.sheets.len() {
let Some(sh) = st.sheets.get(i).cloned() else { continue };
- if !targets.is_empty() && !targets.contains(&sh.body) {
+ let eligible = all
+ || if targets.is_empty() {
+ st.bodies.iter().any(|b| b.name == sh.body && (b.component == f.component || b.feature == f.id))
+ } else {
+ targets.contains(&sh.body)
+ };
+ if !eligible {
continue;
}
let candidates: Vec<(Option, Region2)> = if sh.flat {
- if sh.frame.normal().cross(n).len() > 1e-6 {
- Vec::new()
- } else {
- vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })]
- }
+ if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|region| (None, region)).collect() }
} else {
sh.parallel_panels(&ss.plane)
};
@@ -520,11 +525,9 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 {
continue;
}
- let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::>() } else { panel.outer.polyline(1e-3) };
for r in ®ions {
- let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue };
- let fp = flat.outer.polyline(1e-3);
- let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p));
+ let flat = sh.region_to_flat(pi, &ss.plane, r)?;
+ let overlaps = crate::sheet::flat_regions_overlap(&panel, &flat, &mut overlap_work)?;
if overlaps && !added.iter().any(|x: &Region2| x == &flat) {
added.push(flat);
}
@@ -542,18 +545,6 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
Ok(handled)
}
-fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool {
- let mut inside = false;
- let m = poly.len();
- for i in 0..m {
- let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue };
- if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
- inside = !inside;
- }
- }
- inside
-}
-
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec> {
diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs
index cf3cea21..2ee77cfe 100644
--- a/crates/doc/src/format.rs
+++ b/crates/doc/src/format.rs
@@ -23,13 +23,15 @@
//! - 7: configuration tables can include body material and appearance columns. Version 6
//! rows need no transformation; the version stamp lets older readers request an update
//! instead of encountering an unknown column variant.
+//! - 8: ordered configuration rules and persistent signed line-angle, arc-sweep and slot
+//! dimensions. Version 7 geometry and table values retain their meaning without changes.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
-pub const FORMAT: u32 = 7;
+pub const FORMAT: u32 = 8;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -42,7 +44,7 @@ pub fn version_of(s: &str) -> Option {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
-const JSON_STEPS: [JsonStep; 6] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(())];
+const JSON_STEPS: [JsonStep; 7] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(())];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
@@ -133,6 +135,7 @@ pub fn read(s: &str) -> Result {
)));
}
check_numbers(&v, 0)?;
+ crate::config::validate_rules_json(&v)?;
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
step(&mut v)?;
}
@@ -242,7 +245,7 @@ mod tests {
let upgraded = read(&legacy.to_string()).unwrap();
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
- assert_eq!(upgraded.format, "solvecraft/7");
+ assert_eq!(upgraded.format, format_string(FORMAT));
assert!(upgraded.configuration_matches());
legacy.as_object_mut().unwrap().remove("configs");
assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
@@ -251,7 +254,7 @@ mod tests {
}
#[test]
- fn version_seven_styles_roundtrip_and_newer_versions_are_refused_before_columns() {
+ fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("configured styles");
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
@@ -261,17 +264,72 @@ mod tests {
}];
d.apply_configuration("Blue steel").unwrap();
let mut value: Value = serde_json::from_str(&d.to_json()).unwrap();
- assert_eq!(value["format"], "solvecraft/7");
+ value["format"] = Value::from("solvecraft/7");
+ let original_table = value["configs"].clone();
let roundtrip = read(&value.to_string()).unwrap();
assert_eq!(roundtrip, d);
+ assert_eq!(serde_json::to_value(&roundtrip.configs).unwrap(), original_table);
+ assert_eq!(roundtrip.format, "solvecraft/8");
assert!(roundtrip.configuration_matches());
value["format"] = Value::from(format_string(FORMAT + 1));
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
+ value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true});
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolveCraft") && error.contains("update SolveCraft"), "{error}");
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
}
+ #[test]
+ fn version_seven_origin_sketch_keeps_geometry_dimension_ids_and_expressions() {
+ use solvecraft_geom::Vec2;
+ use solvecraft_sketch::{ConstraintKind, Sketch};
+ let mut d = Document::new("legacy origin dimensions");
+ d.set_param("reach", "25 mm", Some("mm"), None).unwrap();
+ let dim = d.new_model_param("reach", "mm");
+ let mut sketch = Sketch::new();
+ let end = sketch.add_point(Vec2::new(25.0, 0.0), Some("tip")).unwrap();
+ let line = sketch.add_line_pts(0, end, Some("edge")).unwrap();
+ sketch.add_constraint(ConstraintKind::Horizontal { l: line }, None).unwrap();
+ sketch.add_constraint(ConstraintKind::Length { l: line, value: 25.0 }, Some(dim)).unwrap();
+ d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Dimensioned")).unwrap();
+ let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
+ legacy["format"] = Value::from("solvecraft/7");
+ let features = legacy["features"].clone();
+ let params = legacy["params"].clone();
+ let upgraded = read(&legacy.to_string()).unwrap();
+ assert_eq!(upgraded, d);
+ let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
+ assert_eq!(upgraded_json["features"], features);
+ assert_eq!(upgraded_json["params"], params);
+ assert_eq!(read(&upgraded.to_json()).unwrap(), upgraded);
+ }
+
+ #[test]
+ fn version_eight_persistent_angle_sweep_and_slot_dimensions_roundtrip() {
+ use solvecraft_geom::Vec2;
+ use solvecraft_sketch::{ConstraintKind, Sketch};
+ let mut d = Document::new("persistent typed dimensions");
+ let heading = d.new_model_param("45 deg", "deg");
+ let sweep = d.new_model_param("180 deg", "deg");
+ let overall = d.new_model_param("10 mm", "mm");
+ let mut sketch = Sketch::new();
+ let end = sketch.add_point(Vec2::new(2.0, 2.0), None).unwrap();
+ let line = sketch.add_line_pts(0, end, None).unwrap();
+ let a = sketch.add_arc(Vec2::ZERO, Vec2::new(0.0, 2.0), Vec2::new(0.0, -2.0), [None; 3], None).unwrap();
+ let b = sketch.add_arc(Vec2::new(6.0, 0.0), Vec2::new(6.0, -2.0), Vec2::new(6.0, 2.0), [None; 3], None).unwrap();
+ sketch.add_constraint(ConstraintKind::LineAngle { l: line, value: std::f64::consts::FRAC_PI_4 }, Some(heading)).unwrap();
+ sketch.add_constraint(ConstraintKind::ArcSweep { c: a, value: std::f64::consts::PI }, Some(sweep)).unwrap();
+ sketch.add_constraint(ConstraintKind::SlotOverallLength { a, b, value: 10.0 }, Some(overall)).unwrap();
+ d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
+ let mut saved: Value = serde_json::from_str(&d.to_json()).unwrap();
+ assert_eq!(saved["format"], "solvecraft/8");
+ assert_eq!(read(&saved.to_string()).unwrap(), d);
+ saved["format"] = Value::from(format_string(FORMAT + 1));
+ saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension");
+ let error = read(&saved.to_string()).unwrap_err().to_string();
+ assert!(error.contains("newer SolveCraft") && !error.contains("unknown variant"), "{error}");
+ }
+
#[test]
fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
let mut d = Document::new("input repair");
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index f4b6cfc9..be6802b2 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
- state_in_frame, world_state,
+ state_in_frame, state_in_frame_at, world_state,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solvecraft_kernel as kernel;
diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs
index e2b3d795..e7ce630d 100644
--- a/crates/doc/src/params.rs
+++ b/crates/doc/src/params.rs
@@ -502,6 +502,53 @@ impl Document {
Ok(())
}
+ /// Change existing expressions against their final dependency graph, independent of
+ /// column order. Unlike `change_param`, this never creates a missing parameter.
+ pub fn change_params_atomic(&mut self, changes: &[(String, String)]) -> Result<()> {
+ if changes.len() > crate::config::MAX_COLUMNS {
+ return Err(DocError::Invalid("too many parameter changes".into()));
+ }
+ let mut names = BTreeSet::new();
+ let mut changed = BTreeSet::new();
+ let (_, before_errors) = self.param_values();
+ let mut next = self.clone();
+ for (name, expression) in changes {
+ if !names.insert(name.clone()) {
+ return Err(DocError::Invalid(format!("duplicate parameter `{name}`")));
+ }
+ if expression.len() > 4096 || expression.trim().is_empty() {
+ return Err(DocError::Expr("expression is empty or too long".into()));
+ }
+ if let Some(p) = next.params.iter_mut().find(|p| p.name == *name) {
+ if p.expr != expression.trim() {
+ changed.insert(name.clone());
+ }
+ p.expr = expression.trim().to_string();
+ continue;
+ }
+ let hit = next.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k)));
+ let Some((i, k)) = hit else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
+ let kind = next.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless);
+ let normalized = next.with_design_unit(expression, kind);
+ let Some(feature) = next.features.get_mut(i) else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
+ let Some((_, target, _)) = feature.kind.inputs_mut().into_iter().nth(k) else {
+ return Err(DocError::Unknown(format!("parameter `{name}`")));
+ };
+ if *target != normalized {
+ changed.insert(name.clone());
+ }
+ *target = normalized;
+ }
+ let (_, errors) = next.param_values();
+ for (name, error) in errors {
+ if changed.contains(&name) || !before_errors.contains_key(&name) {
+ return Err(DocError::Expr(format!("parameter `{name}`: {error}")));
+ }
+ }
+ *self = next;
+ Ok(())
+ }
+
/// The parameters table (user, sketch dimensions, feature inputs).
pub fn param_rows(&self) -> Vec {
let (vals, errs) = self.param_values();
@@ -639,6 +686,8 @@ pub enum ParamUser {
Feature { feature: u64, name: String, input: String },
/// A sketch dimension (`constraint` is its id).
Dimension { feature: u64, name: String, constraint: String },
+ /// A configured column or an expression saved in a configuration row.
+ Configuration { row: Option, column: String },
}
impl ParamUser {
@@ -647,6 +696,10 @@ impl ParamUser {
ParamUser::Parameter { name } => format!("parameter {name}"),
ParamUser::Feature { name, input, .. } => format!("{name} ({input})"),
ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"),
+ ParamUser::Configuration { row, column } => match row {
+ Some(row) => format!("configuration `{row}` ({column})"),
+ None => format!("configuration column {column}"),
+ },
}
}
}
@@ -716,6 +769,24 @@ impl Document {
/// expressions refer to it, and the sketch dimensions it drives.
pub fn param_users(&self, name: &str) -> Vec {
let mut out = Vec::new();
+ for rule in &self.configs.rules {
+ if rule.parameter_references().iter().any(|reference| reference == name) {
+ out.push(ParamUser::Configuration { row: None, column: format!("rule `{}`", rule.name) });
+ }
+ }
+ for (index, column) in self.configs.columns.iter().enumerate() {
+ if let crate::config::Column::Param(target) = column {
+ let key = self.config_key(column);
+ if target == name {
+ out.push(ParamUser::Configuration { row: None, column: key.clone() });
+ }
+ for row in &self.configs.rows {
+ if row.cells.get(index).is_some_and(|value| expr::references(value).iter().any(|reference| reference == name)) {
+ out.push(ParamUser::Configuration { row: Some(row.name.clone()), column: key.clone() });
+ }
+ }
+ }
+ }
for p in &self.params {
if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) {
out.push(ParamUser::Parameter { name: p.name.clone() });
@@ -828,6 +899,9 @@ impl Document {
}
}
// Configuration cells refer to parameters too.
+ for rule in &mut self.configs.rules {
+ rule.rename_parameter(old, new);
+ }
for r in &mut self.configs.rows {
for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) {
if matches!(column, crate::config::Column::Param(_)) {
diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs
index 6bee00c8..1d109626 100644
--- a/crates/doc/src/sheet.rs
+++ b/crates/doc/src/sheet.rs
@@ -16,6 +16,118 @@ use solvecraft_kernel::{self as kernel, Body, EdgeSpec, FaceSpec, SurfSpec};
use crate::expr::{self, Kind, Value};
use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector, mat_inverse, mat_mul};
+// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces.
+// These are work limits, never geometry truncation: a larger input returns a useful error.
+const MAX_FLAT_POINTS: usize = 16_384;
+const MAX_FLAT_PIECES: usize = 4096;
+const MAX_FLAT_WORK: usize = 500_000_000;
+
+fn flat_budget_error() -> DocError {
+ DocError::Invalid("sheet metal flat geometry exceeds the bounded processing budget".into())
+}
+
+pub(crate) struct FlatWork(usize);
+
+impl FlatWork {
+ pub(crate) fn new() -> Self {
+ Self(MAX_FLAT_WORK)
+ }
+ fn charge(&mut self, work: usize) -> Result<()> {
+ self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?;
+ Ok(())
+ }
+}
+
+fn flat_polyline(lp: &Loop2) -> Result> {
+ if lp.segs.len() > MAX_FLAT_POINTS {
+ return Err(flat_budget_error());
+ }
+ let mut points = Vec::new();
+ for seg in &lp.segs {
+ let p = seg.polyline(1e-3);
+ let add = p.len().saturating_sub(1);
+ if points.len().saturating_add(add) > MAX_FLAT_POINTS {
+ return Err(flat_budget_error());
+ }
+ points.extend(p.iter().take(add));
+ }
+ Ok(points)
+}
+
+fn flat_points(polys: &[Vec]) -> Result {
+ let n = polys.iter().try_fold(0usize, |n, p| n.checked_add(p.len()).ok_or_else(flat_budget_error))?;
+ if n > MAX_FLAT_POINTS || polys.len() > MAX_FLAT_PIECES {
+ return Err(flat_budget_error());
+ }
+ Ok(n)
+}
+
+fn planar_region(region: &Region2) -> Result {
+ if region.holes.len() > MAX_FLAT_PIECES {
+ return Err(flat_budget_error());
+ }
+ region_complexity(std::slice::from_ref(region))?;
+ let convert = |lp: &Loop2| -> Result {
+ if lp.segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
+ flat_polyline(lp)?;
+ Ok(lp.clone())
+ } else {
+ // Keep free-form profiles through controlled 0.001 mm chord sampling.
+ Ok(Loop2::polygon(&flat_polyline(lp)?))
+ }
+ };
+ Ok(Region2 {
+ outer: convert(®ion.outer)?.ccw(),
+ holes: region.holes.iter().map(|h| convert(h).map(|l| l.ccw().reversed())).collect::>()?,
+ })
+}
+
+fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> {
+ if regions.len() > MAX_FLAT_PIECES {
+ return Err(flat_budget_error());
+ }
+ let (mut edges, mut points) = (0usize, 0usize);
+ for lp in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) {
+ edges = edges.saturating_add(lp.segs.len());
+ if edges > MAX_FLAT_POINTS {
+ return Err(flat_budget_error());
+ }
+ points = points.saturating_add(flat_polyline(lp)?.len());
+ if points > MAX_FLAT_POINTS {
+ return Err(flat_budget_error());
+ }
+ }
+ Ok((edges, points))
+}
+
+fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result> {
+ let (ae, ap) = region_complexity(a)?;
+ let (be, bp) = region_complexity(b)?;
+ let edges = ae.saturating_add(be);
+ work.charge(edges.saturating_mul(edges).saturating_add(edges.saturating_mul(ap.saturating_add(bp))).saturating_mul(8))?;
+ let regions = kernel::planar_regions_boolean(a, b, op, 1e-7)?;
+ region_complexity(®ions)?;
+ Ok(regions)
+}
+
+// Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not
+// repeatedly arrange a growing accumulated boundary against just one small region.
+fn material_union(regions: Vec, work: &mut FlatWork) -> Result> {
+ region_complexity(®ions)?;
+ let mut groups: Vec> = regions.into_iter().map(|r| vec![r]).collect();
+ while groups.len() > 1 {
+ let mut next = Vec::new();
+ let mut iter = groups.into_iter();
+ while let Some(a) = iter.next() {
+ match iter.next() {
+ Some(b) => next.push(material_boolean(&a, &b, kernel::BoolOp::Union, work)?),
+ None => next.push(a),
+ }
+ }
+ groups = next;
+ }
+ Ok(groups.pop().unwrap_or_default())
+}
/// A sheet metal rule. Values are expressions; `Thickness` inside them is the rule's thickness.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SheetRule {
@@ -424,65 +536,70 @@ impl SheetBody {
}
}
- /// The pieces of the sheet as cells: base and flange panels and bend zones, each less the
- /// cut-outs over it (cut-outs inside a panel stay exact holes; ones crossing a piece's edge
- /// must be convex polygons).
- fn cells(&self) -> Result> {
- let mut pieces: Vec<(Piece, Vec)> =
- self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), dedup(&r.outer.ccw().polyline(1e-3)))).collect();
- for (i, f) in self.flanges.iter().enumerate() {
- pieces.push((Piece::Zone(i), dedup(&self.zone(f).outer.ccw().polyline(1e-3))));
+ /// The pieces of the sheet as cells: base and flange panels and bend zones, each less
+ /// the combined cut-out material. Planar clipping retains holes and disconnected islands.
+ fn cells_with_work(&self, work: &mut FlatWork) -> Result> {
+ if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES {
+ return Err(flat_budget_error());
}
+ let raw = self
+ .flanges
+ .iter()
+ .fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len)))
+ .saturating_add(self.holes.iter().fold(0usize, |n, h| n.saturating_add(h.outer.segs.len())));
+ if raw > MAX_FLAT_POINTS {
+ return Err(flat_budget_error());
+ }
+ let mut pieces: Vec<(Piece, Region2)> = self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), r)).collect();
+ for (i, f) in self.flanges.iter().enumerate() {
+ pieces.push((Piece::Zone(i), self.zone(f)));
+ }
+ let mut cuts = Vec::new();
+ let mut cut_points = 0usize;
+ for hole in &self.holes {
+ let region = planar_region(hole)?;
+ cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1);
+ if cut_points > MAX_FLAT_POINTS {
+ return Err(flat_budget_error());
+ }
+ cuts.push(region);
+ }
+ let cuts = material_union(cuts, work)?;
let mut out = Vec::new();
- for (piece, poly) in pieces {
- let mut inner: Vec> = Vec::new();
- let mut crossing: Vec> = Vec::new();
- for h in &self.holes {
- let hp = dedup(&h.outer.ccw().polyline(1e-3));
- if matches!(piece, Piece::Panel(_)) && hp.iter().all(|p| point_in(&poly, *p)) {
- inner.push(h.outer.ccw().reversed().segs.clone());
- } else if hp.iter().any(|p| point_in(&poly, *p)) || poly.iter().any(|p| point_in(&hp, *p)) || polys_cross(&poly, &hp) {
- if !is_convex(&hp) {
- return Err(DocError::Invalid(
- "not supported yet: cut-outs over a panel's edge or a bend that are not convex polygons".into(),
- ));
- }
- crossing.push(hp);
+ let mut points = 0usize;
+ for (piece, region) in pieces {
+ let panel = planar_region(®ion)?;
+ let material = if cuts.is_empty() { vec![panel] } else { material_boolean(&[panel], &cuts, kernel::BoolOp::Cut, work)? };
+ for region in material {
+ points = points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1);
+ if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES {
+ return Err(flat_budget_error());
}
- }
- if crossing.is_empty() {
- let outer = Loop2::polygon(&poly).segs;
- out.push(Cell {
- piece,
- outer: if matches!(piece, Piece::Panel(None)) { self.base.outer.ccw().segs.clone() } else { outer },
- holes: inner,
- });
- continue;
- }
- let mut parts = vec![poly];
- for h in &crossing {
- parts = parts.iter().flat_map(|p| convex_diff(p, h)).collect();
- }
- let loops = merge_loops(&parts);
- let (outers, holes): (Vec