diff --git a/Cargo.lock b/Cargo.lock index 6e030559..d4f35623 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,6 +452,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -731,6 +740,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -771,6 +789,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -801,6 +829,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1254,6 +1292,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2905,6 +2953,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3085,6 +3144,7 @@ dependencies = [ "log", "serde", "serde_json", + "sha2", "solvecraft-geom", "solvecraft-kernel", "solvecraft-sketch", @@ -3567,6 +3627,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 192451fd..37bf16ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +sha2 = "0.10" # geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it. truck-modeling = "0.6" diff --git a/README.md b/README.md index 6694fd22..e72cbe63 100644 --- a/README.md +++ b/README.md @@ -23,21 +23,33 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). ## What works -- **Sketches** on the origin planes, offset planes or planar faces: lines, rectangles (2-point, - 3-point, centre), circles (centre, 2-point, 3-point), arcs (3-point, centre), polygons, slots - and points; construction geometry. +- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles, + arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry, + projection, trimming, fillets and chamfers, including corners at the origin. + New face attachments retain supported construction identities and chosen occurrences; + missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md). + Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation. - **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent, equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical, radius, diameter and angle dimensions driven by parameters. Over-constraining edits are rejected; fully constrained geometry turns black. - **Profiles** found automatically, including regions split by lines and holes inside outlines. -- **Features** on a parametric timeline: extrude (one side, flipped, symmetric, two-sided, start - offset; new body, join, cut, intersect), revolve, fillet and chamfer (straight convex edges), - box, cylinder, sphere, torus, combine, move. The timeline rebuilds incrementally from the first - changed feature; it can be rolled back, and features can be suppressed, renamed, edited and - deleted. -- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`). +- **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer, + shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry. + The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered + and deleted. Supported geometry and current limitations are described in [ROADMAP.md](ROADMAP.md). + New revolves preserve symmetric and two-sided angular placement across reversed axes; + older designs retain their saved convention when reopened or edited. +- **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`, + `angle / 2`), ordered rules, feature suppression and per-row body materials/appearances. +- **Assemblies** with nested components and occurrences, joints, motion studies, contact sets, + interference and material-aware mass properties. Recorded body transfers preserve modelling + history and world placement; 3D sketch wires retain their coordinate frames through edits. +- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns, + retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables; + plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear + separately from through-hole outlines in the flat preview and DXF. - **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ; diff --git a/ROADMAP.md b/ROADMAP.md index 001165a6..d71f1706 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,15 +1,15 @@ # 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 | 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) | -| 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,20 +46,20 @@ 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. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | 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) | 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 | -| 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 | +| 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 and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), 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. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and 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. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | 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/11"`, 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,25 +84,113 @@ 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 `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; references resolve by name first and warn when the entity split or vanished. Kernel-reported face history (exact through coincident surfaces) is not wired in yet. + New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face + source certificates with checked target and author occurrence paths. A missing or changed + source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while + retaining current geometry and dimension identities. Unsupported modified faces need an + explicit fixed plane. Legacy attachments retain their original resolver behavior. - 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). +- Extend intersects exact ellipses and rational/high-degree splines without moving the line or + circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance + certificate and refine within bounded node limits while retaining existing IDs. Singular, + self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth + closed natural-spline seams remain limitations. New explicitly closed fit splines and closed + offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older + natural splines are preserved. Clicking the starting fit point deliberately closes a new loop. + Projected analytic boundaries follow nested loop identities through resizing, including + stationary holes and rounded corners. An inset that consumes a corner arc still reports + a collapse error; changing the associated output topology remains unfinished. +- Extrude and Revolve editors retain original direction, extent expressions, explicit targets, + axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names + and selected occurrence frames; cancel and successful edits preserve one-step undo history. + Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides + use the sketch's actual target, author or uniquely placed third-component frame. + Revolve rotators follow the actual signed axis and support symmetric and two-sided extents; + dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis + symmetric geometry is preserved through format 11's explicit legacy convention. New revolves + use the sketch-normal direction for each selected profile, including To Object and curved + target trimming, independently of the selected axis sign. +- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave + outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact + radial walls. New features infer a unique eligible cylinder when the face field is empty; + ambiguous targets require an explicit face. Saved target + and author occurrence paths retain the chosen placements; missing/split faces refuse rather + than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces + remain unsupported. STEP round-trip and independent radial/volume checks cover this subset. - 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. + 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. Deep Paste New copies reachable definition graphs, + independent feature/input histories, styles and internal motion records, with bounded geometry + checks and explicit external-link notices. Paste New selects the copy while preserving the active + authoring context. Imported periodic STEP seams survive placement and reexport with shared + topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership, + material, placements and machining data through edits, copying, undo and reopen. New 3D wires + record their coordinate frame; legacy wires migrate from known provenance without changing + coordinates. Broken legacy links with missing provenance can require explicit refresh or relink. + 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 styles and certified semantic face styles are available; unsupported lineage refuses + atomically, and stale certified face paint cannot jump to another surface. 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. + Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and + unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves + exact holes and retained islands, including the union of multiple selected profiles. Unsupported + tapered or bend-crossing machining refuses atomically instead of restoring material later. +- 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/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index e6f6dcaa..b520e18b 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -1,6 +1,6 @@ Third-party software in SolveCraft -SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 419 crates below. +SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 426 crates below. Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. SolveCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. @@ -44,6 +44,7 @@ bit-set 0.10.0 (Apache-2.0 OR MIT) bit-vec 0.9.1 (Apache-2.0 OR MIT) bitflags 1.3.2 (MIT/Apache-2.0) bitflags 2.13.2 (MIT OR Apache-2.0) +block-buffer 0.10.4 (MIT OR Apache-2.0) block2 0.5.1 (MIT) block2 0.6.2 (MIT) blocking 1.7.0 (Apache-2.0 OR MIT) @@ -71,14 +72,17 @@ core-foundation 0.9.4 (MIT OR Apache-2.0) core-foundation-sys 0.8.7 (MIT OR Apache-2.0) core-graphics 0.23.2 (MIT OR Apache-2.0) core-graphics-types 0.1.3 (MIT OR Apache-2.0) +cpufeatures 0.2.17 (MIT OR Apache-2.0) crc32fast 1.5.2 (MIT OR Apache-2.0) crossbeam-deque 0.8.8 (MIT OR Apache-2.0) crossbeam-epoch 0.9.21 (MIT OR Apache-2.0) crossbeam-utils 0.8.23 (MIT OR Apache-2.0) crunchy 0.2.4 (MIT) +crypto-common 0.1.7 (MIT OR Apache-2.0) cursor-icon 1.2.0 (MIT OR Apache-2.0 OR Zlib) derive_arbitrary 1.5.0 (MIT OR Apache-2.0) derive_more 0.99.20 (MIT) +digest 0.10.7 (MIT OR Apache-2.0) dispatch 0.2.0 (MIT) dispatch2 0.3.1 (Zlib OR Apache-2.0 OR MIT) dlib 0.5.3 (MIT) @@ -121,6 +125,7 @@ futures-lite 2.6.1 (Apache-2.0 OR MIT) futures-macro 0.3.34 (MIT OR Apache-2.0) futures-task 0.3.34 (MIT OR Apache-2.0) futures-util 0.3.34 (MIT OR Apache-2.0) +generic-array 0.14.7 (MIT) gethostname 1.1.0 (Apache-2.0) getrandom 0.2.17 (MIT OR Apache-2.0) getrandom 0.3.4 (MIT OR Apache-2.0) @@ -278,6 +283,7 @@ serde_core 1.0.229 (MIT OR Apache-2.0) serde_derive 1.0.229 (MIT OR Apache-2.0) serde_json 1.0.151 (MIT OR Apache-2.0) serde_repr 0.1.21 (MIT OR Apache-2.0) +sha2 0.10.9 (MIT OR Apache-2.0) signal-hook-registry 1.4.8 (MIT OR Apache-2.0) simd-adler32 0.3.10 (MIT) simd_cesu8 1.2.0 (Apache-2.0 OR MIT) @@ -324,6 +330,7 @@ truck-topology 0.6.0 (Apache-2.0) ttf-parser 0.25.1 (MIT OR Apache-2.0) type-map 0.5.1 (MIT/Apache-2.0) typeid 1.0.3 (MIT OR Apache-2.0) +typenum 1.20.1 (MIT OR Apache-2.0) uds_windows 1.2.1 (MIT) unicode-general-category 1.1.0 (Apache-2.0) unicode-ident 1.0.26 ((MIT OR Apache-2.0) AND Unicode-3.0) @@ -2457,6 +2464,242 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +block-buffer 0.10.4 +================================================================================ + +Copyright (c) 2018-2019 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +block-buffer 0.10.4, cpufeatures 0.2.17, crypto-common 0.1.7, digest 0.10.7, sha2 0.10.9 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + ================================================================================ branches 0.3.0, rclite 0.2.8 ================================================================================ @@ -3085,6 +3328,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +cpufeatures 0.2.17 +================================================================================ + +Copyright (c) 2020-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ crc32fast 1.5.2 ================================================================================ @@ -3378,6 +3651,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +crypto-common 0.1.7 +================================================================================ + +Copyright (c) 2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ cursor-icon 1.2.0 ================================================================================ @@ -3650,6 +3953,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +digest 0.10.7 +================================================================================ + +Copyright (c) 2017 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ dlib 0.5.3 ================================================================================ @@ -4678,6 +5011,32 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +generic-array 0.14.7 +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2015 Bartłomiej Kamiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ getrandom 0.2.17 ================================================================================ @@ -9293,6 +9652,38 @@ consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. +================================================================================ +sha2 0.10.9 +================================================================================ + +Copyright (c) 2006-2009 Graydon Hoare +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2016 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ signal-hook-registry 1.4.8 ================================================================================ @@ -10179,6 +10570,244 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +typenum 1.20.1 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2014 Paho Lurie-Gregg + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ +typenum 1.20.1 +================================================================================ + +MIT OR Apache-2.0 + +================================================================================ +typenum 1.20.1 +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2014 Paho Lurie-Gregg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ uds_windows 1.2.1 ================================================================================ diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 1ebce064..084aa785 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -16,4 +16,5 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +sha2 = { workspace = true } web-time = { workspace = true } diff --git a/crates/doc/src/anchor_frame.rs b/crates/doc/src/anchor_frame.rs new file mode 100644 index 00000000..713a9e76 --- /dev/null +++ b/crates/doc/src/anchor_frame.rs @@ -0,0 +1,21 @@ +//! Presentation/projection state for an exact selected planar source placement. +use crate::{DocError, Document, FaceAnchor, ModelBody, ModelState, Result}; +use std::borrow::Cow; + +pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anchor: &FaceAnchor, author: u64) -> Result> { + let frame = anchor.author_frame(doc, author)?; + let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?; + let relative = anchor.source_to_author(doc, author, raw)?; + let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned(); + let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solvecraft_kernel::transform_matrix(&raw.body, relative)? }; + let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature); + body.component = raw.component; + body.transferred = raw.transferred; + body.source_name = raw.source_name.clone(); + body.source_transform = crate::mat_mul(&relative, &raw.source_transform); + body.imported = raw.imported.clone(); + body.names = crate::naming::NameCell::known(crate::naming::face_names(raw)); + let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?; + *slot = body; + Ok(Cow::Owned(output)) +} diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index f5f8b431..12a577d1 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -45,6 +45,15 @@ pub struct FaceLook { /// The face's persistent name (found first). #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, + /// Strict semantic assignments never transfer to a nearby replacement face. + #[serde(default, skip_serializing_if = "is_false")] + pub strict_name: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certificate: Option, +} + +fn is_false(value: &bool) -> bool { + !*value } /// The look a body was imported with, carried through later features (which build new kernel @@ -130,6 +139,24 @@ 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 { + let name = body.definition_name(); + self.appearances + .bodies + .get(name) + .cloned() + .or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned()) + .or_else(|| self.materials.get(name).and_then(|material| material_look(material))) + } + /// Effective body opacity, including paint carried from an imported body. + pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 { + self.model_body_look(body) + .map(|look| look.opacity) + .or_else(|| body.body.paint().map(|paint| paint.opacity as f64)) + .or_else(|| body.imported.as_ref().and_then(|paint| paint.opacity.map(f64::from))) + .unwrap_or(1.0) + } /// 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 +194,27 @@ 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()); + if f.strict_name { + let Some(name) = f.name.as_ref() else { continue }; + let Some(origin_feature) = name.strip_prefix('F').and_then(|name| name.split_once(':')).and_then(|(id, _)| id.parse().ok()) else { + continue; + }; + let target = crate::config::ConfigFaceTarget { + body: f.body.clone(), + origin_feature, + name: crate::naming::strip_piece(name).into(), + point: f.point, + certificate: f.certificate.clone(), + }; + let Ok(indices) = self.configuration_face_indices(b, &target) else { continue }; + for index in indices { + out.retain(|(old, _)| *old != index); + out.push((index, look.clone())); + } + continue; + } // 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); @@ -181,7 +227,9 @@ impl Document { continue; } } - if let Some(i) = face_index_at(b, p) { + if !f.strict_name + && let Some(i) = face_index_at(b, p) + { out.retain(|(j, _)| *j != i); out.push((i, look)); } diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 771d31a3..f284d5fa 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -328,7 +328,8 @@ impl FeatureKind { | FeatureKind::Stitch { .. } | FeatureKind::Thicken { .. } | FeatureKind::Import { .. } - | FeatureKind::MeshImport { .. } => {} + | FeatureKind::MeshImport { .. } + | FeatureKind::BodyTransfer { .. } => {} FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt), FeatureKind::SurfaceTrim { plane, tool, keep, .. } => { map_plane_ref(m, plane); @@ -385,24 +386,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 w = match self.body_offsets.get(&b.name) { + for b in st.bodies.iter().filter(|b| self.model_body_component(b) == comp) { + let name = self.world_body_name(st, &b.name, comp, &path); + let w = match self.model_body_offset(b) { 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/body_transfer.rs b/crates/doc/src/body_transfer.rs new file mode 100644 index 00000000..38b80cbe --- /dev/null +++ b/crates/doc/src/body_transfer.rs @@ -0,0 +1,80 @@ +//! Recorded ownership changes are evaluated at their timeline position. +use crate::{DocError, Document, Mat, ModelBody, ModelState, Result}; +use solvecraft_geom::Vec3; +use std::collections::BTreeSet; + +pub fn valid_transfer_matrix(m: &Mat) -> bool { + let axis = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]); + let (x, y, z) = (axis(0), axis(1), axis(2)); + m.iter().flatten().all(|v| v.is_finite()) + && m[3][..3].iter().all(|v| v.abs() <= 1e9) + && [x, y, z].iter().all(|v| (v.dot(*v) - 1.0).abs() < 1e-6) + && x.dot(y).abs() < 1e-6 + && x.dot(z).abs() < 1e-6 + && y.dot(z).abs() < 1e-6 + && x.cross(y).dot(z) > 0.0 + && [m[0][3], m[1][3], m[2][3]].iter().all(|v| v.abs() < 1e-9) + && (m[3][3] - 1.0).abs() < 1e-9 +} + +/// Validate the stored boundary before allocation, loading, or evaluation. +pub fn validate_body_transfer(doc: &Document, bodies: &[String], from: u64, to: u64, transform: &Mat) -> Result<()> { + if bodies.is_empty() + || bodies.len() > 1000 + || bodies.iter().any(|name| name.trim().is_empty() || name.len() > 512 || name.chars().any(char::is_control)) + || bodies.iter().collect::>().len() != bodies.len() + || !valid_transfer_matrix(transform) + || [from, to].iter().any(|id| *id != 0 && !doc.components.iter().any(|c| c.id == *id)) + { + return Err(DocError::Invalid("invalid body transfer boundary".into())); + } + Ok(()) +} + +pub(crate) fn evaluate(doc: &Document, st: &mut ModelState, bodies: &[String], from: u64, to: u64, transform: Mat) -> Result<()> { + validate_body_transfer(doc, bodies, from, to, &transform)?; + let mut next = st.clone(); + for name in bodies { + let old = st.body(name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?; + if doc.model_body_component(old) != from { + return Err(DocError::Invalid(format!("body `{name}` no longer belongs to transfer source component {from}"))); + } + let shape = if crate::is_identity(&transform) { old.body.clone() } else { solvecraft_kernel::transform_matrix(&old.body, transform)? }; + let mut body = ModelBody::new(old.name.clone(), shape, old.feature); + body.component = to; + body.transferred = true; + body.source_name = old.source_name.clone(); + body.source_transform = crate::mat_mul(&transform, &old.source_transform); + body.imported = old.imported.clone(); + body.names = crate::naming::NameCell::known(crate::naming::face_names(old)); + let slot = next.bodies.iter_mut().find(|b| b.name == *name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?; + *slot = body; + } + crate::body_transfer_sheet::remap_sheet_metadata(&mut next, bodies, &transform)?; + *st = next; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_transfer_constructor_preserves_document_and_allocator() { + let mut doc = Document::default(); + let before = doc.to_json(); + let mut scaled = crate::IDENTITY; + scaled[0][0] = 2.0; + for (bodies, from, transform) in [ + (vec!["Block".into()], 42, crate::IDENTITY), + (vec!["Block".into(), "Block".into()], 0, crate::IDENTITY), + (vec![String::new()], 0, crate::IDENTITY), + (vec![" ".into()], 0, crate::IDENTITY), + (vec!["bad\nname".into()], 0, crate::IDENTITY), + (vec!["Block".into()], 0, scaled), + ] { + assert!(doc.add_feature(crate::FeatureKind::BodyTransfer { bodies, from, to: 0, transform }, None).is_err()); + assert_eq!(doc.to_json(), before); + } + } +} diff --git a/crates/doc/src/body_transfer_sheet.rs b/crates/doc/src/body_transfer_sheet.rs new file mode 100644 index 00000000..10f9297e --- /dev/null +++ b/crates/doc/src/body_transfer_sheet.rs @@ -0,0 +1,54 @@ +//! World-frame metadata at a timeline body ownership boundary. +use solvecraft_geom::Plane; + +use crate::{DocError, Mat, ModelState, Result, apply_point, apply_vector}; + +pub(crate) fn remap_sheet_metadata(st: &mut ModelState, names: &[String], transform: &Mat) -> Result<()> { + if names.len() > 1000 || st.sheets.len().saturating_add(st.threads.len()) > 100_000 || !transform.iter().flatten().all(|v| v.is_finite()) { + return Err(DocError::Invalid("body transfer metadata exceeds its finite processing budget".into())); + } + let names: std::collections::BTreeSet<&str> = names.iter().map(String::as_str).collect(); + if !st.sheets.iter().any(|s| names.contains(s.body.as_str())) && !st.threads.iter().any(|t| names.contains(t.body.as_str())) { + return Ok(()); + } + if !crate::valid_transfer_matrix(transform) { + return Err(DocError::Invalid("sheet and thread transfer requires a rigid placement".into())); + } + let frames: Vec<_> = st + .sheets + .iter() + .enumerate() + .filter(|(_, s)| names.contains(s.body.as_str())) + .map(|(i, s)| { + let frame = Plane::new(apply_point(transform, s.frame.origin), apply_vector(transform, s.frame.x), apply_vector(transform, s.frame.y)) + .ok_or_else(|| DocError::Invalid("invalid transferred sheet frame".into()))?; + Ok((i, frame)) + }) + .collect::>()?; + let threads: Vec<_> = st + .threads + .iter() + .enumerate() + .filter(|(_, t)| names.contains(t.body.as_str())) + .map(|(i, t)| { + let point = apply_point(transform, t.axis_point); + let axis = apply_vector(transform, t.axis).normalized().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?; + if !point.is_finite() { + return Err(DocError::Invalid("invalid transferred thread placement".into())); + } + Ok((i, point, axis)) + }) + .collect::>()?; + for (i, frame) in frames { + if let Some(s) = st.sheets.get_mut(i) { + s.frame = frame; + } + } + for (i, point, axis) in threads { + if let Some(t) = st.threads.get_mut(i) { + t.axis_point = point; + t.axis = axis; + } + } + Ok(()) +} diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 81272631..e60c2c7c 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -49,7 +49,10 @@ impl FeatureKind { FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Emboss { sketch, .. } + | FeatureKind::SheetBase { sketch, .. } + | FeatureKind::SheetContour { sketch, .. } | FeatureKind::Rib { sketch, .. } => sk(sketch), + FeatureKind::Patch { sketch: Some(s), .. } => sk(s), FeatureKind::Sweep { sketch, path_sketch, .. } => { sk(sketch); sk(path_sketch); @@ -74,3 +77,387 @@ impl FeatureKind { } } } + +/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels. +pub fn remap_topology(name: &str, ids: &BTreeMap, bodies: &BTreeMap) -> String { + // Imported names belong to the complete canonical body prefix, even when that prefix + // happens to contain text resembling a feature token. + if let Some((body, suffix)) = name.rsplit_once(":face:") + && let Some(new) = bodies.get(body) + { + return format!("{new}:face:{suffix}"); + } + let mut out = String::new(); + let mut at = 0; + while at < name.len() { + let rest = &name[at..]; + if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@') | Some(b':') | Some(b'|'))) { + let end = rest.as_bytes().iter().skip(1).take_while(|b| b.is_ascii_digit()).count() + 1; + if end > 1 + && matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.')) + && let Ok(id) = rest[1..end].parse::() + && let Some(new) = ids.get(&id) + { + out.push_str(&format!("F{new}")); + at += end; + continue; + } + } + let Some(ch) = rest.chars().next() else { break }; + out.push(ch); + at += ch.len_utf8(); + } + // Import faces have a canonical body prefix rather than a feature token. + if let Some((body, suffix)) = out.rsplit_once(":face:") + && let Some(new) = bodies.get(body) + { + return format!("{new}:face:{suffix}"); + } + out +} + +fn mapped_name(n: &mut String, names: &BTreeMap) { + if let Some(new) = names.get(n) { + *n = new.clone(); + } +} + +/// Simultaneous generated-parameter substitution: map values may themselves be old keys. +pub fn clone_expression(source: &str, parameters: &BTreeMap) -> String { + let mut result = source.to_string(); + let mut temporary = Vec::new(); + for (index, (old, new)) in parameters.iter().enumerate() { + let mut token = format!("__clone_reference_{index}"); + while source.contains(&token) || parameters.values().any(|n| n == &token) { + token.push('_'); + } + result = crate::expr::rename_reference(&result, old, &token); + temporary.push((token, new)); + } + for (token, new) in temporary { + result = crate::expr::rename_reference(&result, &token, new); + } + result +} + +fn remap_geo( + g: &mut crate::construct::GeoRef, + ids: &BTreeMap, + names: &BTreeMap, + bodies: &BTreeMap, + depth: usize, +) -> crate::Result<()> { + use crate::construct::GeoRef; + if depth > 64 { + return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into())); + } + match g { + GeoRef::SketchPoint { sketch, .. } | GeoRef::SketchCurve { sketch, .. } => { + if let Some(new) = ids.get(sketch) { + *sketch = *new; + } + } + GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies), + GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names), + GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?, + _ => {} + } + Ok(()) +} + +fn remap_plane( + p: &mut PlaneRef, + ids: &BTreeMap, + names: &BTreeMap, + bodies: &BTreeMap, + depth: usize, +) -> crate::Result<()> { + if depth > 64 { + return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into())); + } + match p { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1)?, + PlaneRef::Construction { name } => mapped_name(name, names), + PlaneRef::Face { name, anchor, .. } => { + if let Some(name) = name { + *name = remap_topology(name, ids, bodies); + } + if let Some(anchor) = anchor { + anchor.remap_target(ids, bodies)?; + } + } + _ => { + for r in p.refs_mut() { + remap_geo(r, ids, names, bodies, depth + 1)?; + } + } + } + Ok(()) +} + +fn remap_link( + source: &mut solvecraft_sketch::LinkSource, + ids: &BTreeMap, + names: &BTreeMap, + bodies: &BTreeMap, + depth: usize, +) { + use solvecraft_sketch::LinkSource; + if depth > 64 { + return; + } + match source { + LinkSource::Edge { body, .. } + | LinkSource::Face { body, .. } + | LinkSource::Body { body } + | LinkSource::Vertex { body, .. } + | LinkSource::Iso { body, .. } + | LinkSource::Spun { body, .. } => mapped_name(body, bodies), + LinkSource::SketchCurve { sketch, .. } | LinkSource::SketchPoint { sketch, .. } => { + if let Some(new) = ids.get(sketch) { + *sketch = *new; + } + } + LinkSource::OnSurface { sketch, body, .. } => { + if let Some(new) = ids.get(sketch) { + *sketch = *new; + } + mapped_name(body, bodies); + } + LinkSource::Intersection { a, b } => { + remap_link(a, ids, names, bodies, depth + 1); + remap_link(b, ids, names, bodies, depth + 1); + } + LinkSource::Plane { name } => mapped_name(name, names), + _ => {} + } +} + +impl crate::Feature { + /// Clone identities through typed reference fields. Sketch-local entity IDs stay local. + pub fn remap_clone( + &mut self, + ids: &BTreeMap, + names: &BTreeMap, + bodies: &BTreeMap, + parameters: &BTreeMap, + ) -> crate::Result<()> { + use FeatureKind as K; + match &self.kind { + K::Sketch { plane, sketch } => { + checked_plane(plane, 0)?; + for link in &sketch.links { + checked_link(&link.source, 0)?; + } + } + K::ConstructionPlane { plane } + | K::Split { plane, .. } + | K::SplitFace { plane, .. } + | K::Mirror { plane, .. } + | K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?, + K::Draft { neutral, .. } => checked_plane(neutral, 0)?, + K::ReplaceFace { target, .. } => checked_plane(target, 0)?, + K::ConstructionAxis { def } => { + for r in def.refs() { + checked_geo(r, 0)?; + } + } + K::ConstructionPoint { def } => { + for r in def.refs() { + checked_geo(r, 0)?; + } + } + _ => {} + } + self.kind.remap_refs(ids, names); + for n in &mut self.body_names { + mapped_name(n, bodies); + } + for n in &mut self.participants { + mapped_name(n, bodies); + } + for n in &mut self.param_names { + mapped_name(n, parameters); + } + for n in self.face_names.iter_mut().chain(&mut self.edge_names) { + *n = remap_topology(n, ids, bodies); + } + for (_, e, _) in self.kind.inputs_mut() { + *e = clone_expression(e, parameters); + } + for e in self.kind.point_expr_mut().into_iter().flatten() { + *e = clone_expression(e, parameters); + } + match &mut self.kind { + K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies), + K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => { + mapped_name(&mut tool.body, bodies) + } + _ => {} + } + match &mut self.kind { + K::Extrude { targets, .. } + | K::Revolve { targets, .. } + | K::Loft { targets, .. } + | K::Sweep { targets, .. } + | K::Pipe { targets, .. } + | K::Emboss { targets, .. } => { + for n in targets { + mapped_name(n, bodies); + } + } + K::Combine { target, tools, .. } => { + mapped_name(target, bodies); + for n in tools { + mapped_name(n, bodies); + } + } + K::Pattern { bodies: list, .. } + | K::Mirror { bodies: list, .. } + | K::Scale { bodies: list, .. } + | K::BoundingSolid { bodies: list, .. } + | K::Align { bodies: list, .. } + | K::Remove { bodies: list } + | K::Stitch { bodies: list, .. } + | K::Move { bodies: list, .. } + | K::BodyTransfer { bodies: list, .. } => { + for n in list { + mapped_name(n, bodies); + } + } + K::Thicken { bodies: list, targets, .. } => { + for n in list.iter_mut().chain(targets) { + mapped_name(n, bodies); + } + } + K::BoundaryFill { tools, .. } => { + for n in tools { + mapped_name(n, bodies); + } + } + K::Fillet { body, .. } + | K::Chamfer { body, .. } + | K::Shell { body, .. } + | K::Draft { body, .. } + | K::SplitFace { body, .. } + | K::OffsetFace { body, .. } + | K::DeleteFace { body, .. } + | K::ReplaceFace { body, .. } + | K::SheetFlange { body, .. } + | K::SheetHem { body, .. } + | K::SheetFold { body, .. } + | K::SheetUnfold { body, .. } + | K::Boss { body, .. } + | K::Lip { body, .. } + | K::SnapFit { body, .. } + | K::Rest { body, .. } + | K::Patch { body, .. } => { + if let Some(n) = body { + mapped_name(n, bodies); + } + } + K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => { + mapped_name(body, bodies) + } + _ => {} + } + match &mut self.kind { + K::Sketch { plane, sketch } => { + remap_plane(plane, ids, names, bodies, 0)?; + for c in &mut sketch.constraints { + if let Some(n) = &mut c.param { + mapped_name(n, parameters); + } + } + for link in &mut sketch.links { + remap_link(&mut link.source, ids, names, bodies, 0); + if let Some(n) = &mut link.face_name { + *n = remap_topology(n, ids, bodies); + } + } + } + K::ConstructionPlane { plane } + | K::Split { plane, .. } + | K::SplitFace { plane, .. } + | K::Mirror { plane, .. } + | K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?, + K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?, + K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?, + K::ConstructionAxis { def } => { + for r in def.refs_mut() { + remap_geo(r, ids, names, bodies, 0)?; + } + } + K::ConstructionPoint { def } => { + for r in def.refs_mut() { + remap_geo(r, ids, names, bodies, 0)?; + } + } + _ => {} + } + Ok(()) + } +} + +fn checked_geo(g: &crate::construct::GeoRef, depth: usize) -> crate::Result<()> { + if depth > 64 { + return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into())); + } + if let crate::construct::GeoRef::Plane { plane } = g { + checked_plane(plane, depth + 1)?; + } + Ok(()) +} +fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> { + if depth > 64 { + return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into())); + } + match p { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => checked_plane(base, depth + 1)?, + PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => { + checked_geo(a, depth + 1)?; + checked_geo(b, depth + 1)?; + } + PlaneRef::Perpendicular { plane, line } => { + checked_geo(plane, depth + 1)?; + checked_geo(line, depth + 1)?; + } + PlaneRef::ThreePoints { a, b, c } => { + checked_geo(a, depth + 1)?; + checked_geo(b, depth + 1)?; + checked_geo(c, depth + 1)?; + } + PlaneRef::Tangent { face, .. } => checked_geo(face, depth + 1)?, + PlaneRef::AlongPath { path, .. } => checked_geo(path, depth + 1)?, + _ => {} + } + Ok(()) +} +fn checked_link(source: &solvecraft_sketch::LinkSource, depth: usize) -> crate::Result<()> { + if depth > 64 { + return Err(crate::DocError::Invalid("copy link nesting exceeds 64".into())); + } + if let solvecraft_sketch::LinkSource::Intersection { a, b } = source { + checked_link(a, depth + 1)?; + checked_link(b, depth + 1)?; + } + Ok(()) +} + +#[cfg(test)] +mod clone_tests { + use super::*; + #[test] + fn imported_names_and_topology_tokens_have_separate_identity() { + let ids = BTreeMap::from([(1, 2), (10, 20)]); + let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]); + assert_eq!(remap_topology("F1:Housing:face:0", &ids, &bodies), "F1:Housing (copy):face:0"); + assert_eq!(remap_topology("F1:+x|F10:end@F1.2", &ids, &bodies), "F2:+x|F20:end@F2.2"); + assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label"); + } + #[test] + fn parameter_remap_is_simultaneous_and_token_scoped() { + let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]); + assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm"); + } +} diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs index 88c3941d..db242353 100644 --- a/crates/doc/src/config.rs +++ b/crates/doc/src/config.rs @@ -1,11 +1,24 @@ //! 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}; use crate::{DocError, Document, Result}; +#[path = "config_rules.rs"] +mod rules; +pub use rules::*; +#[path = "config_styles.rs"] +mod styles; +pub use styles::*; +#[path = "config_certificates.rs"] +mod certificates; +pub use certificates::*; +#[path = "config_clone.rs"] +mod clone; +pub use clone::*; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Column { @@ -13,12 +26,20 @@ 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), + /// Shared component definition's direct appearance. + ComponentAppearance(u64), + /// A certified semantic face lineage in a shared definition. + FaceAppearance(ConfigFaceTarget), } #[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, } @@ -28,67 +49,235 @@ 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; -impl ConfigTable { - pub fn is_empty(&self) -> bool { - self.columns.is_empty() && self.rows.is_empty() +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.rules.is_empty() + } +} + +/// 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()), + /// 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 display key: `param:` or `suppress:`. + /// 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, 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()), + Column::ComponentAppearance(id) => { + Some(self.appearances.components.get(id).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()) + } + Column::FaceAppearance(target) => { + Some(self.config_face_look(target).and_then(|look| serde_json::to_string(&look).ok()).unwrap_or_default()) + } + } + } + + /// 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}"), + Column::ComponentAppearance(id) => format!("component_appearance:{id}"), + Column::FaceAppearance(target) => format!("face_appearance:{}", target.key()), } } /// 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; } + 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); + } + }, + Column::ComponentAppearance(id) => { + if *id == 0 || !next.components.iter().any(|component| component.id == *id) { + return Err(DocError::Unknown(format!("component {id} is gone"))); + } + match appearance_cell(v)? { + Some(look) => { + next.appearances.components.insert(*id, look); + } + None => { + next.appearances.components.remove(id); + } + } + } + Column::FaceAppearance(target) => { + next.certify_config_face(target)?; + next.appearances.faces.retain(|face| !target.matches(face)); + if let Some(look) = appearance_cell(v)? { + next.appearances.faces.push(crate::appearance::FaceLook { + body: target.body.clone(), + point: target.point, + look, + name: Some(target.name.clone()), + strict_name: true, + certificate: target.certificate.clone(), + }); + } + } } } 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. 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; + } + 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()), + Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()), + Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look), }) } } @@ -97,6 +286,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"); @@ -114,4 +350,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/config_certificates.rs b/crates/doc/src/config_certificates.rs new file mode 100644 index 00000000..6c6b7396 --- /dev/null +++ b/crates/doc/src/config_certificates.rs @@ -0,0 +1,414 @@ +//! Certificates are producer identities, never a nearest-surface selection. +use crate::{DocError, Document, FeatureKind, ModelBody, Result, SolvedSketch}; +use serde::{Deserialize, Serialize}; +use solvecraft_geom::Seg2; + +fn valid_signature(outer: &[String], holes: &[Vec]) -> bool { + let valid = |ids: &[String]| { + !ids.is_empty() && ids.len() <= 2000 && ids.iter().all(|id| !id.is_empty() && id.len() <= 128) && ids.windows(2).all(|pair| pair[0] < pair[1]) + }; + valid(outer) + && holes.len() <= 128 + && holes.iter().all(|ids| valid(ids)) + && outer.len() + holes.iter().map(Vec::len).sum::() <= 2000 + && holes.windows(2).all(|pair| pair[0] < pair[1]) + && holes.iter().flatten().all(|id| outer.binary_search(id).is_err()) + && holes.iter().flatten().collect::>().len() == holes.iter().map(Vec::len).sum::() +} +fn valid_signature_json(outer: Option<&serde_json::Value>, holes: Option<&serde_json::Value>) -> bool { + let valid = |value: &serde_json::Value| { + value.as_array().is_some_and(|ids| { + !ids.is_empty() + && ids.len() <= 2000 + && ids.iter().all(|id| id.as_str().is_some_and(|id| !id.is_empty() && id.len() <= 128)) + && ids.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str()) + }) + }; + let Some(outer) = outer.filter(|value| valid(value)).and_then(serde_json::Value::as_array) else { + return false; + }; + let Some(holes) = holes.and_then(serde_json::Value::as_array).filter(|holes| holes.len() <= 128) else { + return false; + }; + if !holes.iter().all(valid) { + return false; + } + let total = outer.len() + holes.iter().filter_map(serde_json::Value::as_array).map(Vec::len).sum::(); + let ids: Vec<_> = outer.iter().filter_map(serde_json::Value::as_str).collect(); + total <= 2000 + && holes + .iter() + .filter_map(serde_json::Value::as_array) + .flatten() + .filter_map(serde_json::Value::as_str) + .collect::>() + .len() + == total - outer.len() + && holes + .iter() + .filter_map(serde_json::Value::as_array) + .flatten() + .filter_map(serde_json::Value::as_str) + .all(|id| ids.binary_search(&id).is_err()) + && holes.windows(2).all(|pair| { + let a: Vec<_> = pair[0].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect(); + let b: Vec<_> = pair[1].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect(); + a < b + }) +} +pub(crate) fn cap_key(outer: &[String], holes: &[Vec]) -> String { + use sha2::{Digest, Sha256}; + let mut hash = Sha256::new(); + for ids in std::iter::once(outer).chain(holes.iter().map(Vec::as_slice)) { + hash.update((ids.len() as u64).to_le_bytes()); + for id in ids { + hash.update((id.len() as u64).to_le_bytes()); + hash.update(id.as_bytes()); + } + } + format!("{:x}", hash.finalize()) +} +pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option { + if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::() > 4096 || region.holes.len() > 128 { + return None; + } + let signature = |outline: &solvecraft_geom::Loop2| { + let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::>>()?; + ids.sort(); + ids.dedup(); + Some(ids) + }; + let outer = signature(®ion.outer)?; + let mut holes = region.holes.iter().map(signature).collect::>>()?; + holes.sort(); + valid_signature(&outer, &holes).then_some((outer, holes)) +} + +/// Charge all source-curve comparisons before matching; over-budget tools stay usable but uncertified. +type CapBindings = Vec>>; +pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_kernel::Body], budget: usize) -> Option { + use solvecraft_kernel::FaceSource; + if sketch.sketch.curves.len() > 2000 { + return None; + } + let mut faces = 0usize; + let mut comparisons = 0usize; + for body in bodies { + for source in body.face_sources().into_iter().flatten() { + faces = faces.checked_add(1)?; + if faces > 50_000 { + return None; + } + let segments = match source { + Some(FaceSource::ExtrudeSide { .. }) => 1, + Some(FaceSource::ExtrudeCap { region, .. }) => { + region.outer.segs.len().checked_add(region.holes.iter().map(|hole| hole.segs.len()).sum::())? + } + _ => 0, + }; + comparisons = comparisons.checked_add(segments.checked_mul(sketch.sketch.curves.len())?)?; + if comparisons > budget { + return None; + } + } + } + let mut counts = std::collections::BTreeMap::new(); + let mut bindings = Vec::with_capacity(bodies.len()); + for body in bodies { + let mut cached: Option<(&solvecraft_geom::Region2, Option)> = None; + let mut values = Vec::new(); + for source in body.face_sources().into_iter().flatten() { + let signature = if let Some(FaceSource::ExtrudeCap { region, high, .. }) = source { + let signature = match &cached { + Some((previous, signature)) if *previous == region => signature.clone(), + _ => exact_region_signature(sketch, region), + }; + cached = Some((region, signature.clone())); + if let Some((outer, holes)) = &signature { + *counts.entry((*high, outer.clone(), holes.clone())).or_insert(0usize) += 1; + } + signature + } else { + None + }; + values.push(signature); + } + bindings.push(values); + } + for (body, values) in bodies.iter().zip(&mut bindings) { + for (source, value) in body.face_sources().into_iter().flatten().zip(values) { + if let (Some(FaceSource::ExtrudeCap { high, .. }), Some((outer, holes))) = (source, value.as_ref()) + && counts.get(&(*high, outer.clone(), holes.clone())) != Some(&1) + { + *value = None; + } + } + } + Some(bindings) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum FaceCertificate { + Box { axis: u8, high: bool }, + ExtrudeCap { sketch: u64, high: bool, outer: Vec, holes: Vec> }, + ExtrudeSide { sketch: u64, curve: String }, + Imported { payload_sha256: String, item: u64, face: u64 }, +} + +impl FaceCertificate { + pub fn remap_features(&mut self, features: &std::collections::BTreeMap) { + if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self + && let Some(mapped) = features.get(sketch) + { + *sketch = *mapped; + } + } + pub fn validate(&self) -> Result<()> { + let valid = match self { + Self::Box { axis, .. } => *axis <= 2, + Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes), + Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128, + Self::Imported { payload_sha256, item, face } => { + *item != 0 + && *face != 0 + && payload_sha256.len() == 64 + && payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + } + }; + if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) } + } +} + +pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()> { + if value.is_null() { + return Ok(()); + } + let fields = value.as_object().ok_or_else(|| DocError::Invalid("face certificate must be an object".into()))?; + let nonzero = |field: &str| fields.get(field).and_then(serde_json::Value::as_u64).is_some_and(|id| id != 0); + let valid = match fields.get("kind").and_then(serde_json::Value::as_str) { + Some("box") => { + fields.len() == 3 + && fields.keys().all(|key| ["kind", "axis", "high"].contains(&key.as_str())) + && fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2) + && fields.get("high").and_then(serde_json::Value::as_bool).is_some() + } + Some("extrude_cap") => { + fields.len() == 5 + && fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str())) + && nonzero("sketch") + && fields.get("high").and_then(serde_json::Value::as_bool).is_some() + && valid_signature_json(fields.get("outer"), fields.get("holes")) + } + Some("extrude_side") => { + fields.len() == 3 + && fields.keys().all(|key| ["kind", "sketch", "curve"].contains(&key.as_str())) + && nonzero("sketch") + && fields.get("curve").and_then(serde_json::Value::as_str).is_some_and(|curve| !curve.is_empty() && curve.len() <= 128) + } + Some("imported") => { + fields.len() == 4 + && fields.keys().all(|key| ["kind", "payload_sha256", "item", "face"].contains(&key.as_str())) + && nonzero("item") + && nonzero("face") + && fields + .get("payload_sha256") + .and_then(serde_json::Value::as_str) + .is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + } + _ => false, + }; + if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) } +} + +/// Match a generated segment to one exact analytic sketch curve. Coincident +/// candidates are ambiguous and never resolved by distance or candidate order. +pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option { + if sketch.sketch.curves.len() > 2000 { + return None; + } + let mut found = None; + for (index, curve) in sketch.sketch.curves.iter().enumerate() { + if curve.construction + || !matches!( + curve.kind, + solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Arc { .. } | solvecraft_sketch::CurveKind::Circle { .. } + ) + { + continue; + } + let matches = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind { + sketch + .sketch + .point(c) + .is_some_and(|center| exact_segment_contains(&Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }, generated)) + } else { + sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated)) + }; + if matches { + if found.is_some() { + return None; + } + found = Some(curve.id.clone()); + } + } + found +} + +fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool { + match (original, generated) { + (Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => { + let direction = *b - *a; + let scale = direction.len().max((*d - *c).len()).max(1.); + let interval = |point: solvecraft_geom::Vec2| { + let t = (point - *a).dot(direction) / direction.len2(); + (-1e-10..=1. + 1e-10).contains(&t) + }; + direction.len() > 1e-12 + && direction.cross(*c - *a).abs() <= 1e-10 * scale * scale + && direction.cross(*d - *a).abs() <= 1e-10 * scale * scale + && interval(*c) + && interval(*d) + } + (Seg2::Arc { center: a, radius: ar, start: os, sweep: ow }, Seg2::Arc { center: b, radius: br, start: gs, sweep: gw }) => { + let tau = std::f64::consts::TAU; + let tolerance = 1e-10; + let length = ow.abs(); + let generated_length = gw.abs(); + if a.dist(*b) > 1e-10 * ar.max(*br).max(1.) + || (ar - br).abs() > 1e-10 * ar.max(*br).max(1.) + || length > tau + tolerance + || generated_length > length + tolerance + { + return false; + } + if length >= tau - tolerance { + return true; + } + let original_low = if *ow >= 0. { *os } else { os + ow }; + let generated_low = if *gw >= 0. { *gs } else { gs + gw }; + let offset = (generated_low - original_low).rem_euclid(tau); + let offset = if offset > tau - tolerance { 0. } else { offset }; + offset + generated_length <= length + tolerance + } + _ => false, + } +} + +impl Document { + pub fn configuration_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result { + let names = crate::naming::face_names(body); + let mut name = names + .get(index) + .map(|name| crate::naming::strip_piece(name).to_string()) + .ok_or_else(|| DocError::Unknown("face source name missing".into()))?; + let origin = match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) { + Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. }) + | Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. }) + | Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. }) + | Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer, + _ => name + .strip_prefix('F') + .and_then(|name| name.split_once(':')) + .and_then(|(id, _)| id.parse().ok()) + .ok_or_else(|| DocError::Invalid("face source producer missing".into()))?, + }; + let certificate = self.configuration_face_certificate(body, index)?; + if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate { + name = format!("F{origin}:side:{curve}"); + } else if !name.starts_with(&format!("F{origin}:")) { + name = format!("F{origin}:face:{index}"); + } + let inverse = crate::mat_inverse(&body.source_transform).ok_or_else(|| DocError::Invalid("invalid definition placement".into()))?; + Ok(crate::config::ConfigFaceTarget { + body: body.definition_name().into(), + origin_feature: origin, + name, + point: crate::apply_point(&inverse, point), + certificate, + }) + } + pub fn configuration_face_certificate(&self, body: &ModelBody, index: usize) -> Result> { + let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else { + return Ok(None); + }; + if let solvecraft_kernel::FaceSource::Imported { item, face, payload_sha256: Some(digest), producer: Some(producer) } = source { + if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) { + return Err(DocError::Invalid("import source producer missing".into())); + } + let certificate = FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face }; + certificate.validate()?; + return Ok(Some(certificate)); + } + let certificate = match source { + solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => { + if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { .. })) { + return Err(DocError::Invalid("side source producer missing".into())); + } + FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() } + } + _ => return Ok(None), + }; + certificate.validate()?; + Ok(Some(certificate)) + } + pub fn strong_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result { + let mut target = self.configuration_face_target(body, index, point)?; + match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) { + Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis, high, .. }) => { + target.origin_feature = *producer; + let axis_name = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid box face axis".into()))?; + target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" }); + target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high }); + } + Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => { + target.origin_feature = *producer; + target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)); + target.certificate = Some(FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() }); + } + _ => {} + } + self.certify_config_face(&target)?; + Ok(target) + } +} + +pub(crate) fn payload_digest(payload: &str) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(payload.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn certificate_budgets_apply_before_saved_target_and_paint_decode() { + for certificate in [ + json!({"kind":"extrude_side","sketch":1,"curve":"x".repeat(1_000_000)}), + json!({"kind":"imported","payload_sha256":"0".repeat(64),"item":0,"face":1}), + json!({"kind":"imported","payload_sha256":"g".repeat(64),"item":1,"face":1}), + json!({"kind":"extrude_side","sketch":1,"curve":"c1","extra":[[[[[]]]]]}), + ] { + assert!(validate_certificate_json(&certificate).is_err()); + let target = json!({"configs":{"columns":[{"face_appearance":{"body":"B","origin_feature":1,"name":"F1:side:c1","point":[0,0,0],"certificate":certificate}}]}}); + assert!(crate::config::validate_styles_json(&target).is_err()); + let paint = json!({"appearances":{"faces":[{"certificate":certificate}]}}); + assert!(crate::config::validate_styles_json(&paint).is_err()); + } + assert!(validate_certificate_json(&json!({"kind":"imported","payload_sha256":"a".repeat(64),"item":1,"face":2})).is_ok()); + assert!(validate_certificate_json(&json!({"kind":"extrude_side","sketch":1,"curve":"c1"})).is_ok()); + } + #[test] + fn analytic_arc_interval_refuses_unsampled_gaps_and_accepts_wrap_reversal() { + let radians = |degrees: f64| degrees.to_radians(); + let arc = |start, sweep| Seg2::Arc { center: solvecraft_geom::Vec2::default(), radius: 10., start: radians(start), sweep: radians(sweep) }; + assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., 360.)), "five point probes missed the missing ten-degree gap"); + assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., -360.))); + assert!(exact_segment_contains(&arc(350., 40.), &arc(355., 20.))); + assert!(exact_segment_contains(&arc(350., 40.), &arc(15., -20.))); + assert!(exact_segment_contains(&arc(30., -40.), &arc(15., -20.))); + assert!(!exact_segment_contains(&arc(350., 40.), &arc(345., 20.))); + assert!(exact_segment_contains(&arc(0., 360.), &arc(20., -360.))); + } +} diff --git a/crates/doc/src/config_clone.rs b/crates/doc/src/config_clone.rs new file mode 100644 index 00000000..da1aac92 --- /dev/null +++ b/crates/doc/src/config_clone.rs @@ -0,0 +1,218 @@ +//! Configuration graph remapping for independent component copies. +use super::{Column, ConfigTable}; +use crate::{DocError, Result}; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Default)] +pub struct ConfigCloneMaps { + pub features: BTreeMap, + pub components: BTreeMap, + pub bodies: BTreeMap, + pub parameters: BTreeMap, +} + +/// Simultaneous token-reference substitution, including maps whose values are other keys. +pub(super) fn remap_expression(source: &str, parameters: &BTreeMap) -> String { + let refs = crate::expr::references(source); + let mut result = source.to_string(); + let mut replacements = Vec::new(); + for (index, reference) in refs.iter().enumerate() { + if let Some(mapped) = parameters.get(reference) { + let mut temporary = format!("_config_clone_{index}"); + while refs.contains(&temporary) || parameters.contains_key(&temporary) || parameters.values().any(|value| value == &temporary) { + temporary.push('_'); + } + result = crate::expr::rename_reference(&result, reference, &temporary); + replacements.push((temporary, mapped)); + } + } + for (temporary, mapped) in replacements { + result = crate::expr::rename_reference(&result, &temporary, mapped); + } + result +} + +impl ConfigTable { + /// Append mapped columns/cells/rules atomically; source targets and active row remain intact. + pub fn clone_targets(&mut self, maps: &ConfigCloneMaps) -> Result<()> { + if maps.features.values().chain(maps.components.values()).any(|id| *id == 0) + || maps.parameters.values().any(|name| crate::Document::param_name_problem(name).is_some()) + || maps.bodies.values().any(|name| name.is_empty() || name.len() > 128) + { + return Err(DocError::Invalid("invalid configuration clone identity mapping".into())); + } + if self.columns.len() > super::MAX_COLUMNS + || self.rows.len() > super::MAX_ROWS + || self.rules.len() > super::MAX_RULES + || self.rows.iter().any(|row| row.cells.len() != self.columns.len()) + { + return Err(DocError::Invalid("cannot clone an incomplete configuration table".into())); + } + let mut next = self.clone(); + for (index, column) in self.columns.iter().enumerate() { + if let Column::FaceAppearance(target) = column + && maps.bodies.contains_key(&target.body) + && !maps.features.contains_key(&target.origin_feature) + { + return Err(DocError::Invalid(format!( + "cannot copy configured face `{}`: imported foreign body has no certified cloned lineage", + target.name + ))); + } + let mapped = match column { + Column::Param(name) => maps.parameters.get(name).cloned().map(Column::Param), + Column::Suppress(id) => maps.features.get(id).copied().map(Column::Suppress), + Column::Material(name) => maps.bodies.get(name).cloned().map(Column::Material), + Column::Appearance(name) => maps.bodies.get(name).cloned().map(Column::Appearance), + Column::ComponentAppearance(id) => maps.components.get(id).copied().map(Column::ComponentAppearance), + Column::FaceAppearance(target) => { + maps.bodies.get(&target.body).zip(maps.features.get(&target.origin_feature)).map(|(body, origin)| { + let mut target = target.clone(); + target.body = body.clone(); + target.name = target + .name + .strip_prefix(&format!("F{}:", target.origin_feature)) + .map(|role| format!("F{origin}:{role}")) + .unwrap_or_else(|| target.name.clone()); + target.origin_feature = *origin; + if let Some(certificate) = &mut target.certificate { + certificate.remap_features(&maps.features); + } + Column::FaceAppearance(target) + }) + } + }; + if let Some(mapped) = mapped { + if mapped == *column { + continue; + } + if next.columns.iter().any(|column| { + *column == mapped + || matches!((column,&mapped),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name) + }) { + return Err(DocError::Invalid("configuration copy target collides with an existing column".into())); + } + if next.columns.len() >= super::MAX_COLUMNS { + return Err(DocError::Invalid("configuration copy exceeds column limit".into())); + } + next.columns.push(mapped); + for (source, destination) in self.rows.iter().zip(&mut next.rows) { + let value = source.cells.get(index).cloned().unwrap_or_default(); + let value = if matches!(column, Column::Param(_)) { remap_expression(&value, &maps.parameters) } else { value }; + if value.len() > 4096 { + return Err(DocError::Invalid("configuration copied value exceeds length limit".into())); + } + destination.cells.push(value); + } + } + } + for rule in &self.rules { + if let Some(mut copied) = rule.clone_for_targets(maps) { + if next.rules.len() >= super::MAX_RULES { + return Err(DocError::Invalid("configuration copy exceeds rule limit".into())); + } + let stem: String = rule.id.chars().take(40).collect(); + copied.id = (1..=super::MAX_RULES + 1) + .map(|i| format!("{stem}_copy{i}")) + .find(|id| !next.rules.iter().any(|rule| rule.id == *id)) + .ok_or_else(|| DocError::Invalid("no configuration rule ID available".into()))?; + copied.name = format!("{} (copy)", rule.name.chars().take(120).collect::()); + next.rules.push(copied); + } + } + super::validate_rules_json(&serde_json::json!({"configs":{"rules":next.rules}}))?; + *self = next; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ConfigFaceTarget, ConfigRow, ConfigRule, RuleCondition, RuleEffect}; + #[test] + fn clone_preserves_source_variations_and_remaps_only_typed_targets_and_tokens() { + let mut table = ConfigTable { + columns: vec![ + Column::Param("d1".into()), + Column::Param("d2".into()), + Column::Appearance("Old".into()), + Column::ComponentAppearance(2), + Column::FaceAppearance(ConfigFaceTarget { + body: "Old".into(), + origin_feature: 3, + name: "F3:+z".into(), + point: solvecraft_geom::Vec3::new(1., 1., 2.), + certificate: None, + }), + ], + rows: vec![ConfigRow { + name: "Default".into(), + cells: vec!["d1 * 2 + d2".into(), "4 mm".into(), "d1".into(), String::new(), String::new()], + }], + active: Some("Default".into()), + rules: vec![ConfigRule { + id: "Rule1".into(), + name: "Rule1".into(), + enabled: true, + length_unit: Some("mm".into()), + condition: RuleCondition::Suppression { feature: 3, value: false }, + effect: RuleEffect::DriveParameter { parameter: "d1".into(), expression: "d2 * 2 + d1".into() }, + }], + }; + let original = table.clone(); + let maps = ConfigCloneMaps { + features: BTreeMap::from([(3, 7)]), + components: BTreeMap::from([(2, 8)]), + bodies: BTreeMap::from([("Old".into(), "New".into())]), + parameters: BTreeMap::from([("d1".into(), "copy_d1".into()), ("d2".into(), "copy_d2".into())]), + }; + table.clone_targets(&maps).unwrap(); + assert_eq!(&table.columns[..5], &original.columns); + assert_eq!(&table.rows[0].cells[..5], &original.rows[0].cells); + assert_eq!(table.columns[5], Column::Param("copy_d1".into())); + assert_eq!(table.rows[0].cells[5], "copy_d1 * 2 + copy_d2"); + assert_eq!(table.rows[0].cells[7], "d1"); // appearance strings are never parameter expressions + assert_eq!(table.columns[8], Column::ComponentAppearance(8)); + let Column::FaceAppearance(face) = &table.columns[9] else { panic!() }; + assert_eq!(face.name, "F7:+z"); + assert_eq!(face.body, "New"); + assert_eq!(table.rules[0], original.rules[0]); + assert!( + matches!(&table.rules[1].effect,RuleEffect::DriveParameter{parameter,expression} if parameter=="copy_d1" && expression=="copy_d2 * 2 + copy_d1") + ); + assert!(matches!(table.rules[1].condition, RuleCondition::Suppression { feature: 7, .. })); + assert_eq!(table.active, original.active); + let chained = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]); + assert_eq!(remap_expression("d1 * 2 + d2", &chained), "d2 * 2 + d3"); + let collision = ConfigCloneMaps { parameters: chained, ..Default::default() }; + let mut original_copy = original.clone(); + assert!(original_copy.clone_targets(&collision).is_err()); + assert_eq!(original_copy, original); + } + #[test] + fn foreign_strict_lineage_failure_is_atomic() { + let mut table = ConfigTable { + columns: vec![ + Column::Param("width".into()), + Column::FaceAppearance(ConfigFaceTarget { + body: "Foreign".into(), + origin_feature: 3, + name: "F3:+z".into(), + point: solvecraft_geom::Vec3::ZERO, + certificate: None, + }), + ], + rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }], + ..Default::default() + }; + let original = table.clone(); + let maps = ConfigCloneMaps { + bodies: BTreeMap::from([("Foreign".into(), "Copied".into())]), + parameters: BTreeMap::from([("width".into(), "copied_width".into())]), + ..Default::default() + }; + assert!(table.clone_targets(&maps).unwrap_err().to_string().contains("certified")); + assert_eq!(table, original); + } +} diff --git a/crates/doc/src/config_rules.rs b/crates/doc/src/config_rules.rs new file mode 100644 index 00000000..ebd01a3a --- /dev/null +++ b/crates/doc/src/config_rules.rs @@ -0,0 +1,684 @@ +//! 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(); + } + }); + } + pub(super) fn clone_for_targets(&self, maps: &super::ConfigCloneMaps) -> Option { + let mapped = match &self.effect { + RuleEffect::DriveParameter { parameter, .. } + | RuleEffect::ParameterRange { parameter, .. } + | RuleEffect::AllowParameterValues { parameter, .. } => maps.parameters.contains_key(parameter), + RuleEffect::DriveSuppression { feature, .. } => maps.features.contains_key(feature), + }; + if !mapped { + return None; + } + let mut copy = self.clone(); + copy.visit_parameters(&mut |text, expression| { + *text = if expression { + super::clone::remap_expression(text, &maps.parameters) + } else { + maps.parameters.get(text).cloned().unwrap_or_else(|| text.clone()) + }; + }); + fn remap(condition: &mut RuleCondition, maps: &super::ConfigCloneMaps) { + match condition { + RuleCondition::Suppression { feature, .. } => { + *feature = maps.features.get(feature).copied().unwrap_or(*feature); + } + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => { + for condition in conditions { + remap(condition, maps); + } + } + RuleCondition::Not { condition } => remap(condition, maps), + _ => {} + } + } + remap(&mut copy.condition, maps); + if let RuleEffect::DriveSuppression { feature, .. } = &mut copy.effect { + *feature = maps.features.get(feature).copied().unwrap_or(*feature); + } + Some(copy) + } + 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/config_styles.rs b/crates/doc/src/config_styles.rs new file mode 100644 index 00000000..18d053f8 --- /dev/null +++ b/crates/doc/src/config_styles.rs @@ -0,0 +1,305 @@ +//! Stable configuration style targets. Display indices and placement names are never saved. +use super::Column; +use crate::{DocError, Document, FeatureKind, ModelState, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use solvecraft_geom::Vec3; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigFaceTarget { + pub body: String, + pub origin_feature: u64, + pub name: String, + /// Definition-frame point for diagnostics and rendering; never a resolver fallback. + pub point: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certificate: Option, +} +impl ConfigFaceTarget { + pub fn key(&self) -> String { + // Length-prefix encoding keeps punctuation in body/name unambiguous. + format!("{}:{}{}", self.body.len(), self.body, self.name) + } + pub fn matches(&self, face: &crate::appearance::FaceLook) -> bool { + face.body == self.body && face.name.as_ref().is_some_and(|name| crate::naming::strip_piece(name) == self.name) + } + pub fn validate(&self) -> Result<()> { + if let Some(certificate) = &self.certificate { + certificate.validate()?; + } + if self.body.is_empty() + || self.body.len() > 128 + || self.name.is_empty() + || self.name.len() > 512 + || self.origin_feature == 0 + || [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9) + { + return Err(DocError::Invalid("invalid configuration face target".into())); + } + Ok(()) + } +} + +pub fn validate_face_target_json(target: &Value) -> Result<()> { + let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?; + if !(4..=5).contains(&fields.len()) + || fields.keys().any(|key| !["body", "origin_feature", "name", "point", "certificate"].contains(&key.as_str())) + || fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128) + || fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512) + || fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0) + || fields.get("point").and_then(Value::as_array).is_none_or(|point| { + point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) + }) + { + return Err(DocError::Invalid("invalid or oversized configuration face target".into())); + } + if let Some(certificate) = fields.get("certificate") { + super::validate_certificate_json(certificate)?; + } + Ok(()) +} + +/// Budget malformed style nodes before decoding their typed representation. +pub fn validate_styles_json(value: &Value) -> Result<()> { + if let Some(columns) = value.get("configs").and_then(|v| v.get("columns")).and_then(Value::as_array) { + if columns.len() > super::MAX_COLUMNS { + return Err(DocError::Invalid("too many configuration columns".into())); + } + for column in columns { + if let Some(target) = column.get("face_appearance") { + validate_face_target_json(target)?; + } + if let Some(id) = column.get("component_appearance") + && id.as_u64().is_none_or(|id| id == 0) + { + return Err(DocError::Invalid("configuration component ID must be nonzero".into())); + } + } + } + if let Some(faces) = value.get("appearances").and_then(|value| value.get("faces")).and_then(Value::as_array) { + for face in faces { + if let Some(certificate) = face.get("certificate") { + super::validate_certificate_json(certificate)?; + if !certificate.is_null() + && (face.get("strict_name").and_then(Value::as_bool) != Some(true) + || face.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128) + || face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512) + || face.get("point").and_then(Value::as_array).is_none_or(|point| { + point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) + })) + { + return Err(DocError::Invalid("invalid certified face appearance".into())); + } + } + } + } + Ok(()) +} + +impl Document { + /// Certify named generator lineage; generic index-derived naming is unsupported. + pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> { + target.validate()?; + let prefix = format!("F{}:", target.origin_feature); + let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?; + if crate::naming::strip_piece(&target.name) != target.name { + return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into())); + } + let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?; + if let Some(certificate) = &target.certificate { + let valid = match (certificate, &feature.kind) { + (super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => { + ["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" })) + } + (super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => { + sketch == producer_sketch && extent.taper.is_none() + && role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes)) + } + (super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => { + sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}") + && self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. })))) + } + (super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::().is_ok()), + _ => false, + }; + return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) }; + } + let valid = match &feature.kind { + FeatureKind::Box { .. } => ["+x", "-x", "+y", "-y", "+z", "-z"].contains(&role), + FeatureKind::Cylinder { .. } => ["top", "bottom", "side"].contains(&role), + // Curved side lineage currently uses centroid/nearest-curve heuristics; + // certify caps only until the naming layer supplies proven curve provenance. + FeatureKind::Extrude { extent, .. } => { + extent.from.is_none() + && extent.start_offset.as_deref().is_none_or(|value| { + crate::expr::is_literal(value) + && crate::expr::eval_with(value, &|name| Err(DocError::Unknown(name.into()))) + .ok() + .and_then(|value| value.to_kind(crate::expr::Kind::Length).ok()) + .is_some_and(|value| value.abs() < 1e-12) + }) + && extent.distance2.is_none() + && ["start", "top", "bottom"].contains(&role) + } + FeatureKind::Revolve { angle2, symmetric, .. } => angle2.is_none() && !symmetric && ["start", "end"].contains(&role), + _ => false, + }; + if !valid { + return Err(DocError::Invalid("this face has no supported stable semantic lineage".into())); + } + Ok(()) + } + /// Verify the current named pieces describe one surface, not ambiguous generated roles. + pub fn configuration_face_indices(&self, body: &crate::ModelBody, target: &ConfigFaceTarget) -> Result> { + self.certify_config_face(target)?; + if body.body.is_mesh() || body.definition_name() != target.body { + return Err(DocError::Invalid("configured face definition does not match".into())); + } + if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) { + let indices: Vec<_> = body + .body + .face_sources() + .into_iter() + .flatten() + .enumerate() + .filter_map(|(index, source)| { + let matched = match (&target.certificate, source) { + ( + Some(super::FaceCertificate::Box { axis, high }), + Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }), + ) => *producer == target.origin_feature && axis == current_axis && high == current_high, + ( + Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }), + Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { + producer, + sketch: current_sketch, + high: current_high, + outer: current_outer, + holes: current_holes, + .. + }), + ) => { + *producer == target.origin_feature + && sketch == current_sketch + && high == current_high + && outer == current_outer + && holes == current_holes + } + _ => false, + }; + matched.then_some(index) + }) + .collect(); + if indices.len() != 1 { + return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into())); + } + return Ok(indices); + } + if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate { + let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| { + matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index) + }).collect(); + if indices.is_empty() { + return Err(DocError::Unknown("constructed side certificate no longer exists".into())); + } + return Ok(indices); + } + if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate { + let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| { + matches!(source, Some(solvecraft_kernel::FaceSource::Imported { item: current_item, face: current_face, payload_sha256: Some(digest), producer: Some(producer) }) if current_item == item && current_face == face && digest == payload_sha256 && *producer == target.origin_feature).then_some(index) + }).collect(); + if indices.is_empty() { + return Err(DocError::Unknown("immutable import face certificate no longer exists".into())); + } + return Ok(indices); + } + let names = crate::naming::face_names(body); + let indices: Vec = + names.iter().enumerate().filter(|(_, name)| crate::naming::strip_piece(name) == target.name).map(|(index, _)| index).collect(); + if indices.is_empty() { + return Err(DocError::Unknown(format!("configured face `{}` on `{}` no longer exists", target.name, target.body))); + } + if let Some(certificate) = &target.certificate { + for index in &indices { + if self.configuration_face_certificate(body, *index)?.as_ref() != Some(certificate) { + return Err(DocError::Invalid("face pieces do not share the exact certified source".into())); + } + } + return Ok(indices); + } + if target.name.ends_with(":side") { + if indices.len() != 1 { + return Err(DocError::Invalid("split curved face lineage is not yet certified".into())); + } + return Ok(indices); + } + // Caps and box faces must share an oriented plane. This rejects generic cap-role + // collisions while permitting the coplanar pieces of a split supported face. + let mesh = body.mesh(); + let mut plane: Option<(Vec3, Vec3)> = None; + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if indices.binary_search(&(*face as usize)).is_err() { + continue; + } + let Some([a, b, c]) = mesh.tri(triangle) else { continue }; + let Some(normal) = (b - a).cross(c - a).normalized() else { continue }; + if let Some((point, base)) = plane { + let magnitude = [point.x, point.y, point.z, a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z] + .iter() + .fold(body.body.size(), |scale, value| scale.max(value.abs())); + let tolerance = (magnitude * f64::EPSILON * 128.0).max(1e-7); + if normal.dot(base) < 1.0 - 1e-6 || [a, b, c].iter().any(|vertex| (*vertex - point).dot(base).abs() > tolerance) { + return Err(DocError::Invalid("face lineage names more than one oriented surface".into())); + } + } else { + plane = Some((a, normal)); + } + } + if plane.is_none() { + return Err(DocError::Invalid("configured face has no valid surface".into())); + } + Ok(indices) + } + pub fn config_face_look(&self, target: &ConfigFaceTarget) -> Option { + self.appearances.faces.iter().rev().find(|face| target.matches(face)).map(|face| face.look.clone()) + } + /// Validate against the final rebuilt definition state. Missing names never pick nearby faces. + pub fn configuration_style_status(&self, state: &ModelState) -> Result> { + let mut result = Vec::new(); + for column in &self.configs.columns { + match column { + Column::ComponentAppearance(id) => { + let component = + self.components.iter().find(|c| c.id == *id).ok_or_else(|| DocError::Unknown(format!("component {id} is gone")))?; + result.push(serde_json::json!({"key":self.config_key(column),"target":component.name,"shared":true,"dormant":false})); + } + Column::FaceAppearance(target) => { + self.certify_config_face(target)?; + let dormant = match state.body(&target.body) { + None if self.feature(target.origin_feature).is_some_and(|feature| feature.suppressed) => true, + None => return Err(DocError::Unknown(format!("configured face body `{}` is gone", target.body))), + Some(body) => { + self.configuration_face_indices(body, target)?; + false + } + }; + result.push(serde_json::json!({"key":self.config_key(column),"target":format!("{} / {}",target.body,target.name),"shared":true,"dormant":dormant})); + } + _ => {} + } + } + Ok(result) + } + /// Maintain columns and row cell alignment when component definitions are deleted. + pub fn remove_configuration_components(&mut self, ids: &[u64]) { + let keep: Vec = self.configs.columns.iter().map(|c| !matches!(c,Column::ComponentAppearance(id) if ids.contains(id))).collect(); + let mut mask = keep.iter(); + self.configs.columns.retain(|_| mask.next().copied().unwrap_or(false)); + for row in &mut self.configs.rows { + let mut mask = keep.iter(); + row.cells.retain(|_| mask.next().copied().unwrap_or(false)); + } + self.appearances.components.retain(|id, _| !ids.contains(id)); + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 0fce5008..6fb8c4c0 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -56,15 +56,18 @@ pub enum PlaneRef { AlongPath { path: Box, t: String }, /// Through a straight edge (or line, axis) and square to a plane (or planar face). Perpendicular { plane: Box, line: Box }, - /// A planar body face: found again on each evaluation (the face with this plane's normal - /// nearest `at`), so sketches on it follow when earlier features change; `plane` is the - /// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's - /// persistent name (found first; see `naming`). + /// A planar body face. Certified attachments resolve only their exact source and report + /// an error when it is lost; `plane` then supplies a repair guide. Legacy references without + /// an anchor try the persistent `name`, then the nearest parallel face at `at`, and retain + /// the picked `plane` as their historical fallback. Face { plane: Plane, at: Vec3, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, + /// Exact attachment identity for newly certified faces; absent on legacy designs. + #[serde(default, skip_serializing_if = "Option::is_none")] + anchor: Option>, }, } @@ -116,6 +119,16 @@ pub enum Direction { Symmetric, } +/// Angular starts in older designs used the raw axis sign. New revolves measure starts +/// along the same sketch-normal sweep direction as the kernel. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RevolveConvention { + #[default] + LegacyAxisStart, + SketchNormal, +} + /// Extent of an extrude. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Extent { @@ -161,10 +174,97 @@ pub enum AxisRef { Line { origin: Vec3, dir: Vec3 }, } +/// Persistent cylindrical Emboss selection, authored in the feature component frame. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EmbossFace { + pub body: String, + pub at: Vec3, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub path: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub author_path: Vec, +} + +impl EmbossFace { + /// The selected sketch placement, shared by evaluation and interactive guides. + pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body: &crate::ModelBody) -> Result { + let owner = doc.model_body_component(body); + if sketch_owner == owner { + return Self::component_frame(doc, owner, &self.path); + } + if sketch_owner == author { + return Self::component_frame(doc, author, &self.author_path); + } + let path = doc.first_path(sketch_owner); + let mut component = sketch_owner; + for _ in 0..64 { + if component == 0 { + break; + } + let mut occurrences = doc.occurrences.iter().filter(|o| o.component == component); + let occurrence = occurrences.next().ok_or_else(|| DocError::Invalid("sketch placement no longer exists".into()))?; + if occurrences.next().is_some() { + return Err(DocError::Invalid("sketch placement is ambiguous".into())); + } + component = occurrence.parent; + } + if component != 0 { + return Err(DocError::Invalid("sketch placement exceeds depth limit".into())); + } + Self::component_frame(doc, sketch_owner, &path) + } + + /// Resolve this exact placement without expanding every assembly instance. + pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result { + let owner = doc.model_body_component(body); + let mut frame = Self::component_frame(doc, owner, &self.path)?; + if let Some(offset) = doc.model_body_offset(body) { + frame = crate::mat_mul(&frame, offset); + } + Ok(frame) + } + + /// Resolve one saved component path without expanding other occurrences. + pub fn component_frame(doc: &Document, owner: u64, path: &[u64]) -> Result { + if path.len() > 64 || doc.occurrences.len() > 100_000 { + return Err(DocError::Invalid("selected cylinder path exceeds work limits".into())); + } + let mut frame = crate::IDENTITY; + let mut component = 0; + let mut seen = std::collections::BTreeSet::new(); + for id in path { + if *id == 0 || !seen.insert(*id) { + return Err(DocError::Invalid("invalid selected cylinder path".into())); + } + let occurrence = doc + .occurrences + .iter() + .find(|o| o.id == *id && o.parent == component) + .ok_or_else(|| DocError::Invalid("selected cylinder placement no longer exists".into()))?; + frame = crate::mat_mul(&frame, &occurrence.transform); + component = occurrence.component; + } + if path.is_empty() { + frame = doc.component_transform(owner); + } else if component != owner { + return Err(DocError::Invalid("selected cylinder path belongs to another component".into())); + } + Ok(frame) + } +} + /// Feature definitions. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum FeatureKind { + /// A recorded timeline ownership boundary. Geometry is converted from its + /// current local frame into the destination component frame exactly once. + BodyTransfer { + bodies: Vec, + from: u64, + to: u64, + transform: crate::Mat, + }, Sketch { plane: PlaneRef, sketch: Sketch, @@ -195,6 +295,9 @@ pub enum FeatureKind { /// Symmetric: `angle` split evenly either side of the profile. #[serde(default, skip_serializing_if = "std::ops::Not::not")] symmetric: bool, + /// Missing fields retain the angular placement of existing designs. + #[serde(default)] + convention: RevolveConvention, /// To Object: turn until the face, vertex or point here. #[serde(default, skip_serializing_if = "Option::is_none")] to: Option, @@ -437,13 +540,15 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, }, - /// Raise (emboss) or sink (deboss) sketch profiles on the planar face they lie on. + /// Raise or sink profiles on a planar face, or wrap them on a selected cylindrical wall. Emboss { sketch: u64, profiles: ProfileSel, depth: String, #[serde(default, skip_serializing_if = "std::ops::Not::not")] deboss: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + face: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, }, @@ -1040,7 +1145,8 @@ impl FeatureKind { | K::SurfaceTrim { .. } | K::Move { .. } | K::Import { .. } - | K::MeshImport { .. } => Vec::new(), + | K::MeshImport { .. } + | K::BodyTransfer { .. } => Vec::new(), } } @@ -1065,6 +1171,7 @@ impl FeatureKind { /// Fusion-like type name (timeline tooltip, `inspect`). pub fn type_name(&self) -> &'static str { match self { + FeatureKind::BodyTransfer { .. } => "BodyTransferFeature", FeatureKind::Sketch { .. } => "Sketch", FeatureKind::Extrude { .. } => "ExtrudeFeature", FeatureKind::Revolve { .. } => "RevolveFeature", @@ -1129,6 +1236,7 @@ impl FeatureKind { /// Default name prefix (`Extrude` → `Extrude1`). pub fn base_name(&self) -> &'static str { match self { + FeatureKind::BodyTransfer { .. } => "BodyTransfer", FeatureKind::Sketch { .. } => "Sketch", FeatureKind::Extrude { .. } => "Extrude", FeatureKind::Revolve { .. } => "Revolve", @@ -1220,6 +1328,7 @@ impl FeatureKind { FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(), FeatureKind::Revolve { to: Some(p), .. } => vec![p], FeatureKind::Thread { face, .. } => vec![face], + FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at], FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position], _ => Vec::new(), } @@ -1255,6 +1364,9 @@ impl FeatureKind { if let Some(d) = &extent.taper { v.push(d); } + if let Some(d) = &extent.to_offset { + v.push(d); + } } FeatureKind::Revolve { angle, angle2, .. } => { v.push(angle); @@ -1380,7 +1492,7 @@ impl FeatureKind { FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::SurfaceExtend { distance, .. } => v.push(distance), FeatureKind::Patch { .. } => {} - FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {} + FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {} } v } @@ -1586,7 +1698,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,17 +1707,12 @@ 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 faces: Vec = self .face_colors(b) .into_iter() .map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 }) .collect(); - let opacity = look - .map(|l| l.opacity as f32) - .or_else(|| b.body.paint().map(|p| p.opacity)) - .or_else(|| b.imported.as_ref().and_then(|i| i.opacity)) - .unwrap_or(1.0); + let opacity = self.model_body_opacity(b) as f32; let paint = solvecraft_kernel::Paint { opacity, faces }; if c != b.body.color() || b.body.paint() != Some(&paint) { b.body = b.body.clone().with_color(c).with_paint(Some(paint)); @@ -1621,9 +1728,36 @@ impl Document { /// The component a body belongs to: where it was moved, else its feature's. pub fn body_component(&self, body: &str, feature: u64) -> u64 { + self.features + .iter() + .take(self.marker.unwrap_or(usize::MAX)) + .rev() + .find_map(|f| { + if f.suppressed { + return None; + } + match &f.kind { + FeatureKind::BodyTransfer { bodies, to, .. } if bodies.iter().any(|b| b == body) => Some(*to), + _ => None, + } + }) + .unwrap_or_else(|| self.legacy_body_component(body, feature)) + } + + pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 { self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0)) } + /// Ownership in this evaluated timeline state, rather than a later transfer. + pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 { + if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) } + } + + /// Recorded transfers already converted geometry into the new local frame. + pub fn model_body_offset(&self, body: &crate::ModelBody) -> Option<&crate::Mat> { + if body.transferred { None } else { self.body_offsets.get(&body.name) } + } + /// Add a component under `parent` and return its id. pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result { if parent != 0 && !self.components.iter().any(|c| c.id == parent) { @@ -1704,6 +1838,12 @@ impl Document { if self.features.len() >= MAX_FEATURES { return Err(DocError::Invalid("too many features".into())); } + let component = if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &kind { + crate::validate_body_transfer(self, bodies, *from, *to, transform)?; + *to + } else { + 0 + }; let id = self.next_id.max(1); self.next_id = id + 1; let name = match name { @@ -1716,7 +1856,7 @@ impl Document { suppressed: false, body_names: Vec::new(), param_names: Vec::new(), - component: 0, + component, edge_refs: Vec::new(), edge_names: Vec::new(), face_names: Vec::new(), @@ -1739,9 +1879,26 @@ 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), + crate::config::Column::FaceAppearance(target) => !gone.contains(&target.origin_feature), + _ => true, + }) + .collect(); + self.appearances + .faces + .retain(|face| !face.strict_name || !face.name.as_ref().is_some_and(|name| gone.iter().any(|id| name.starts_with(&format!("F{id}:"))))); if keep.contains(&false) { let mut k = keep.iter(); self.configs.columns.retain(|_| k.next().copied().unwrap_or(true)); @@ -1768,6 +1925,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 +2130,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..b44ede63 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, @@ -29,13 +33,30 @@ pub struct ModelBody { pub(crate) names: crate::naming::NameCell, /// The component the body belongs to (set after each feature). pub component: u64, + /// A timeline transfer has converted this body into its evaluated owner's frame. + pub transferred: bool, /// The look it was imported with, carried through later features. pub imported: Option>, } 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, + transferred: false, + 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 { @@ -256,6 +277,8 @@ impl ModelState { b.name.hash(&mut h); b.key.hash(&mut h); b.feature.hash(&mut h); + b.component.hash(&mut h); + b.transferred.hash(&mut h); } for s in &self.sketches { s.feature.hash(&mut h); @@ -274,6 +297,13 @@ impl ModelState { let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1; let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats }); for b in &mut self.bodies { + if !matches!(f.kind, FeatureKind::BodyTransfer { .. }) + && let Some(old) = before.bodies.iter().find(|old| old.name == b.name && old.transferred) + { + b.transferred = true; + b.component = old.component; + b.source_transform = old.source_transform; + } let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body)); match kept { Some(x) => { @@ -284,7 +314,9 @@ impl ModelState { let mut h = std::collections::hash_map::DefaultHasher::new(); (key, &b.name).hash(&mut h); b.key = h.finish(); - b.names = crate::naming::NameCell::new(Some(origin.clone())); + if !matches!(f.kind, FeatureKind::BodyTransfer { .. }) { + b.names = crate::naming::NameCell::new(Some(origin.clone())); + } // Faces split on purpose stay split through later features (for export). if b.body.split_lines().is_none() && let Some(lines) = before.bodies.iter().find(|x| x.name == b.name).and_then(|x| x.body.split_lines()) @@ -476,8 +508,31 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat /// Where components are placed, for a sketch that sees other components' geometry (on a face, /// or with projections): it is resolved in its own component's view of them. fn placement_digest(doc: &Document, f: &Feature) -> u64 { + let path = match &f.kind { + FeatureKind::Sweep { path_sketch, .. } + | FeatureKind::Pipe { path_sketch, .. } + | FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch), + _ => None, + }; + if let Some(path) = path + && doc.sketch(path).is_ok_and(|sketch| !sketch.wires.is_empty()) + { + let mut hash = std::collections::hash_map::DefaultHasher::new(); + let owner = doc.feature(path).map(|feature| feature.component).unwrap_or(0); + for frame in [doc.component_transform(f.component), doc.component_transform(owner)] { + frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); + } + if let FeatureKind::Sweep { sketch, .. } = &f.kind { + let owner = doc.feature(*sketch).map(|feature| feature.component).unwrap_or(0); + doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); + } + return hash.finish(); + } let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 }; - if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) { + let anchor = crate::face_anchor::plane_face_anchor(plane); + if (doc.occurrences.is_empty() && (anchor.is_none() || doc.body_offsets.is_empty())) + || (!matches!(plane, PlaneRef::Face { .. }) && anchor.is_none() && sketch.links.is_empty()) + { return 0; } let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -614,7 +669,10 @@ impl Model { let mut warning = None; // Face references by name: move each face point onto its named face. let renamed; - let f = if f.face_names.iter().any(|n| !n.is_empty()) { + // Cylindrical emboss resolves its named face in the selected body's frame + // and certifies an analytic anchor; mesh projection here would undo that. + let explicit_emboss = matches!(&f.kind, FeatureKind::Emboss { face: Some(_), .. }); + let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) { let mut g = f.clone(); let mut notes = Vec::new(); for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) { @@ -652,7 +710,7 @@ impl Model { Ok(()) => { // Copies are named after their sources through the instance transforms. let mats = match &f.kind { - FeatureKind::Pattern { pattern, .. } => pattern_transforms(&vals, &state, pattern).unwrap_or_default(), + FeatureKind::Pattern { pattern, .. } => pattern_transforms(doc, &vals, &state, f.component, pattern).unwrap_or_default(), FeatureKind::Mirror { plane, .. } => { doc.resolve_plane_in(&vals, plane, 0, Some(&state)).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default() } @@ -660,7 +718,9 @@ impl Model { }; next.stamp(&state, f, key, mats); for b in &mut next.bodies { - b.component = doc.body_component(&b.name, b.feature); + if !b.transferred { + b.component = doc.legacy_body_component(&b.name, b.feature); + } } state = Arc::new(next); None @@ -702,7 +762,7 @@ fn val(vals: &BTreeMap, e: &str, k: Kind) -> Result { Document::eval_in(vals, e, k) } -fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result> { +pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result> { let ps = &ss.profiles; if ps.is_empty() { return Err(DocError::Invalid(format!("{} has no closed profiles", ss.name))); @@ -767,8 +827,61 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String { } } +fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String { + let base = f.body_names.get(k).filter(|name| !name.trim().is_empty()).map(String::as_str).unwrap_or(source); + unique_body_name(state, base) +} + /// Add tool bodies to the model according to the operation. +fn sheet_boolean_unchanged(body: &Body, tools: &[Body], op: Operation) -> Result<()> { + if sheet_boolean_changes(body, tools, op)? { + return Err(DocError::Invalid("this sheet machining cannot be preserved in its flat material: use a parallel, untapered extrude pocket/cut, or unfold before intersecting; unsupported 3D sheet machining is refused to prevent material restoration".into())); + } + Ok(()) +} + +fn sheet_boolean_changes(body: &Body, tools: &[Body], op: Operation) -> Result { + let united; + let tools: &[Body] = if op == Operation::Intersect { + united = intersection_tool_union(tools)?; + &united + } else { + tools + }; + for tool in tools { + let test = if op == Operation::Cut { BoolOp::Intersect } else { BoolOp::Cut }; + if kernel::boolean(body, tool, test)?.is_some() { + return Ok(true); + } + } + Ok(false) +} + +/// Selected profiles form one machining tool, including disconnected tool material. +/// Balanced unions avoid repeatedly rebuilding a growing aggregate boundary. +fn intersection_tool_union(tools: &[Body]) -> Result> { + if tools.len() > 1000 || tools.iter().fold(0usize, |n, b| n.saturating_add(b.face_count())) > 16_384 { + return Err(DocError::Invalid("intersection tools exceed the bounded geometry budget".into())); + } + let mut groups = tools.to_vec(); + 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(kernel::boolean(&a, &b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("intersection tool union is empty".into()))?) + } + None => next.push(a), + } + } + groups = next; + } + Ok(groups) +} + fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation, targets: &[String]) -> Result<()> { + let tools = if op == Operation::Intersect { intersection_tool_union(&tools)? } else { tools }; // Participants: the feature's own targets, else its explicit participants, else the bodies // of its component (and what it made itself): other components are left alone. let targets: &[String] = if targets.is_empty() { &f.participants } else { targets }; @@ -794,6 +907,13 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation } return Ok(()); } + if matches!(op, Operation::Cut | Operation::Intersect) { + for &i in &target_idx { + if let Some(mb) = state.bodies.get(i).filter(|b| state.sheets.iter().any(|s| s.body == b.name)) { + sheet_boolean_unchanged(&mb.body, &tools, op)?; + } + } + } match op { Operation::Join => { let Some(&first) = target_idx.first() else { return Ok(()) }; @@ -807,6 +927,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 }; @@ -831,9 +953,15 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation i += 1; keep }); + let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect(); + state.threads.retain(|t| live.contains(t.body.as_str())); // 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); @@ -852,7 +980,7 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation } /// Distance that reaches through every target body from the sketch plane (either side). -fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result { +pub fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result { let mut far: f64 = 0.0; let mut any = false; for b in st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)) { @@ -1350,8 +1478,107 @@ fn hole_tools( Ok(tools) } +/// The angular interval used by both the solid and its interactive extent handles. +#[derive(Clone, Copy, Debug)] +pub struct RevolveExtent { + pub start: f64, + pub sweep: f64, + pub rotation_axis: Vec3, + pub display_start: f64, + /// A curved target trims the swept solid and has no single angular boundary. + pub curved_to: bool, +} + +pub fn revolve_extent( + st: &ModelState, + plane: &Plane, + regions: &[Region2], + (origin, axis): (Vec3, Vec3), + (angle, angle2): (f64, Option), + symmetric: bool, + to: Option, +) -> Result { + revolve_extent_with_convention(st, plane, regions, (origin, axis), (angle, angle2), symmetric, to, crate::RevolveConvention::LegacyAxisStart) +} + +#[allow(clippy::too_many_arguments)] +pub fn revolve_extent_with_convention( + st: &ModelState, + plane: &Plane, + regions: &[Region2], + (origin, axis): (Vec3, Vec3), + (angle, angle2): (f64, Option), + symmetric: bool, + to: Option, + convention: crate::RevolveConvention, +) -> Result { + let (start, sweep) = match (to, angle2, symmetric) { + (Some(point), _, _) => { + let points: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); + let centre = if convention == crate::RevolveConvention::SketchNormal { + regions.first().map(|r| plane.to_world(r.centroid())).ok_or_else(|| DocError::Invalid("no profile selected".into()))? + } else { + plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64)) + }; + let projection_axis = if convention == crate::RevolveConvention::SketchNormal { + axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))? + } else { + axis + }; + let flat = |point: Vec3| { + let v = point - origin; + v - projection_axis * v.dot(projection_axis) + }; + let (u, v) = (flat(centre), flat(point)); + let angle_axis = if convention == crate::RevolveConvention::SketchNormal { + if v.len() < 1e-9 { + return Err(DocError::Invalid("the revolve target lies on the axis; pick a point away from the axis".into())); + } + kernel::canonical_revolve_axis( + plane, + regions.first().ok_or_else(|| DocError::Invalid("no profile selected".into()))?, + plane.to_local(origin), + Vec2::new(projection_axis.dot(plane.x), projection_axis.dot(plane.y)), + )? + } else { + -axis + }; + let sweep = angle_axis.dot(u.cross(v)).atan2(u.dot(v)); + (0.0, if sweep <= 1e-9 { sweep + std::f64::consts::TAU } else { sweep }) + } + (None, Some(other), _) => (-other, angle + other), + (None, None, true) => (-angle / 2.0, angle), + (None, None, false) => (0.0, angle), + }; + if !(sweep.is_finite() && start.is_finite() && sweep.abs() > 1e-9 && sweep.abs() <= std::f64::consts::TAU + 1e-9) { + return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into())); + } + let points: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); + let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64)); + let unit_axis = axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?; + let radial_centre = regions.first().map(|region| plane.to_world(region.centroid())).unwrap_or(centre); + let radial = radial_centre - origin - unit_axis * (radial_centre - origin).dot(unit_axis); + let rotation_axis = match (convention, regions.first()) { + (crate::RevolveConvention::SketchNormal, Some(region)) => { + kernel::canonical_revolve_axis(plane, region, plane.to_local(origin), Vec2::new(unit_axis.dot(plane.x), unit_axis.dot(plane.y)))? + } + _ => { + if unit_axis.cross(radial).dot(plane.normal()) < 0.0 { + -unit_axis + } else { + unit_axis + } + } + }; + let display_start = match convention { + crate::RevolveConvention::LegacyAxisStart => start * -unit_axis.dot(rotation_axis), + crate::RevolveConvention::SketchNormal => start, + }; + Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) }) +} + /// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state. -fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) -> Result> { +fn feature_tools(doc: &Document, vals: &BTreeMap, f: &Feature, st: &ModelState) -> Result> { match &f.kind { FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); @@ -1391,11 +1618,12 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric { return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into())); } - let sign = if extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 }; + let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 }; let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) }; + let start_plane = ss.plane.offset(from); return Ok(regions .iter() - .map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper)) + .map(|r| kernel::extrude_tapered(&start_plane, r, len, sign, taper)) .collect::, _>>()?); } let off = from @@ -1453,59 +1681,102 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - let start = if hi > 0.0 { lo + off } else { hi + off }; tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?; } + if *operation == Operation::NewBody && extent.taper.is_none() { + let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000); + tools = tools + .into_iter() + .enumerate() + .map(|(body_index, body)| { + let curves: Vec<_> = body + .face_sources() + .into_iter() + .flatten() + .map(|source| match source { + Some(kernel::FaceSource::ExtrudeSide { segment, .. }) if cap_bindings.is_some() => { + crate::config::exact_sketch_curve(&ss, segment) + } + _ => None, + }) + .collect(); + let caps = + cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]); + body.with_extrude_certificates(f.id, *sketch, &curves)? + .with_extrude_cap_certificates(f.id, *sketch, &caps) + .map_err(DocError::from) + }) + .collect::>>()?; + } Ok(tools) } - FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => { + FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let (o, d) = revolve_axis(&ss, axis)?; - // Where the turn starts (from the profile) and how far it goes. - let (start, ang) = match (to, angle2, symmetric) { - (Some(p), _, _) => { - let (ow, aw) = (ss.plane.to_world(o), ss.plane.dir_to_world(d)); - let pts: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); - let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64)); - let flat = |q: Vec3| { - let v = q - ow; - v - aw * v.dot(aw) - }; - let (u, v) = (flat(c), flat(*p)); - // The revolve turns clockwise about its axis direction (the kernel's sense). - let a = (aw * -1.0).dot(u.cross(v)).atan2(u.dot(v)); - let a = if a <= 1e-9 { a + std::f64::consts::TAU } else { a }; - (0.0, a) - } - (None, Some(a2), _) => { - let (a, b) = (val(vals, angle, Kind::Angle)?, val(vals, a2, Kind::Angle)?); - (-b, a + b) - } - (None, None, true) => { - let a = val(vals, angle, Kind::Angle)?; - (-a / 2.0, a) - } - (None, None, false) => (0.0, val(vals, angle, Kind::Angle)?), + let angles = if to.is_some() { + (0.0, None) + } else { + (val(vals, angle, Kind::Angle)?, angle2.as_ref().map(|a| val(vals, a, Kind::Angle)).transpose()?) }; - if !(ang.abs() > 1e-9 && ang.abs() <= std::f64::consts::TAU + 1e-9) { - return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into())); - } + let extent = revolve_extent_with_convention( + st, + &ss.plane, + ®ions, + (ss.plane.to_world(o), ss.plane.dir_to_world(d)), + angles, + *symmetric, + *to, + *convention, + )?; + let (start, ang) = (extent.start, extent.sweep); + let region_extents = regions + .iter() + .map(|r| { + if *convention == crate::RevolveConvention::SketchNormal && to.is_some() { + revolve_extent_with_convention( + st, + &ss.plane, + std::slice::from_ref(r), + (ss.plane.to_world(o), ss.plane.dir_to_world(d)), + angles, + *symmetric, + *to, + *convention, + ) + } else { + Ok(extent) + } + }) + .collect::>>()?; let (operation, targets) = feature_op(f); let axis_w = ss.plane.dir_to_world(d); let origin_w = ss.plane.to_world(o); - let probe = |p: Vec2| { - let q = ss.plane.to_world(p); - (1..4) - .map(|k| { - let a = ang * k as f64 / 4.0; - let v = q - origin_w; - origin_w + v * a.cos() + axis_w.cross(v) * a.sin() + axis_w * (axis_w.dot(v) * (1.0 - a.cos())) - }) - .collect::>() - }; - let regions: Vec = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect(); + let regions: Vec = regions + .iter() + .zip(®ion_extents) + .map(|(r, region_extent)| { + let rotation_axis = match convention { + crate::RevolveConvention::LegacyAxisStart => axis_w, + crate::RevolveConvention::SketchNormal => kernel::canonical_revolve_axis(&ss.plane, r, o, d)?, + }; + let probe = |p: Vec2| { + let q = ss.plane.to_world(p); + (1..4) + .map(|k| { + let a = region_extent.sweep * k as f64 / 4.0 + + if *convention == crate::RevolveConvention::SketchNormal { region_extent.start } else { 0.0 }; + let v = q - origin_w; + origin_w + v * a.cos() + rotation_axis.cross(v) * a.sin() + rotation_axis * (rotation_axis.dot(v) * (1.0 - a.cos())) + }) + .collect::>() + }; + Ok(grow_profile_for_coplanar(st, r, operation, &targets, &probe)) + }) + .collect::>>()?; // To a curved face: turn past the whole target body, then cut back to its surface // (the piece that starts at the profile). let curved = to.and_then(|p| curved_target_any(st, p)); - if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) { + let curved_body = curved.and_then(|bi| st.bodies.get(bi)); + let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result> { let pts: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64)); let flat = |q: Vec3| { @@ -1515,15 +1786,36 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - let u0 = flat(c); let angle_of = |q: Vec3| { let v = flat(q); - let a = (axis_w * -1.0).dot(u0.cross(v)).atan2(u0.dot(v)); + let a = angle_axis.dot(u0.cross(v)).atan2(u0.dot(v)); if a < 0.0 { a + std::f64::consts::TAU } else { a } }; let far = tb.mesh().positions.iter().map(|q| angle_of(*q)).fold(0.0, f64::max); let sweep = (far + 0.05).min(std::f64::consts::TAU - 1e-3); - let bodies = kernel::revolve(&ss.plane, ®ions, o, d, sweep)?; + let bodies = kernel::revolve(&ss.plane, regions, o, d, sweep)?; let n = ss.plane.normal(); let tol_side = |q: Vec3| if flat(q).dot(u0) > 0.0 { ((q - ss.plane.origin).dot(n)).abs() } else { f64::MAX }; - return trim_to_body(bodies, &tb.body, tol_side); + trim_to_body(bodies, &tb.body, tol_side) + }; + if *convention == crate::RevolveConvention::LegacyAxisStart + && let Some(tb) = curved_body + { + return build_curved(®ions, -axis_w, tb); + } + if *convention == crate::RevolveConvention::SketchNormal { + let mut out = Vec::new(); + for (region, region_extent) in regions.iter().zip(®ion_extents) { + let rotation_axis = kernel::canonical_revolve_axis(&ss.plane, region, o, d)?; + if let Some(tb) = curved_body { + out.extend(build_curved(std::slice::from_ref(region), rotation_axis, tb)?); + continue; + } + let bodies = kernel::revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?; + let m = crate::rigid(Vec3::ZERO, origin_w, rotation_axis, region_extent.start); + for body in bodies { + out.push(if region_extent.start.abs() < 1e-12 { body } else { kernel::transform_matrix(&body, m)? }); + } + } + return Ok(out); } let bodies = kernel::revolve(&ss.plane, ®ions, o, d, ang)?; if start.abs() < 1e-12 { @@ -1600,6 +1892,15 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; + let profile; + let ss = if !ps.sketch.wires.is_empty() { + profile = profile_in_frame(doc, ss, f.component)?; + &profile + } else { + ss + }; + let mapped = path_in_frame(doc, ps, f.component)?; + let ps = mapped.as_ref(); let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?); let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default(); // A path through 3D sketch curves follows them as points. @@ -1612,6 +1913,8 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => { let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; + let mapped = path_in_frame(doc, ps, f.component)?; + let ps = mapped.as_ref(); let r = val(vals, diameter, Kind::Length)? / 2.0; if !(r > 1e-6) { return Err(DocError::Invalid("pipe diameter must be positive".into())); @@ -1676,6 +1979,51 @@ fn pipe_region(vals: &BTreeMap, r: f64, wall: &Option) -> Ok(Region2 { outer: solvecraft_geom::Loop2::circle(Vec2::ZERO, r), holes }) } +fn map_wire(wire: &mut solvecraft_sketch::Wire, matrix: &Mat) { + for point in wire.pts.iter_mut().chain(&mut wire.fit) { + *point = crate::apply_point(matrix, *point); + } +} + +fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result { + let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0); + let inverse = + crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("profile consumer frame is invalid".into()))?; + let relative = crate::mat_mul(&inverse, &doc.component_transform(owner)); + let mut mapped = sketch.clone(); + mapped.plane = Plane::new( + crate::apply_point(&relative, sketch.plane.origin), + crate::apply_vector(&relative, sketch.plane.x), + crate::apply_vector(&relative, sketch.plane.y), + ) + .ok_or_else(|| DocError::Invalid("profile sketch frame is invalid".into()))?; + Ok(mapped) +} + +/// Stored wire coordinates are converted once into the feature consumer frame. +fn path_in_frame<'a>(doc: &Document, sketch: &'a SolvedSketch, consumer: u64) -> Result> { + use std::borrow::Cow; + if sketch.sketch.wires.is_empty() { + return Ok(Cow::Borrowed(sketch)); + } + let owner = doc.feature(sketch.feature).map(|f| f.component).unwrap_or(0); + let inverse = + crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("path consumer frame is not invertible".into()))?; + let relative = crate::mat_mul(&inverse, &doc.component_transform(owner)); + let mut mapped = sketch.clone(); + mapped.plane = Plane::new( + crate::apply_point(&relative, sketch.plane.origin), + crate::apply_vector(&relative, sketch.plane.x), + crate::apply_vector(&relative, sketch.plane.y), + ) + .ok_or_else(|| DocError::Invalid("path sketch frame is invalid".into()))?; + for wire in &mut mapped.sketch.wires { + let matrix = if wire.space == solvecraft_sketch::WireSpace::Component { &relative } else { &inverse }; + map_wire(wire, matrix); + } + Ok(Cow::Owned(mapped)) +} + /// A path that uses 3D sketch curves (wires), as one chain of world points from the end nearest /// `start` (no start: the first curve runs toward the second). `None` when the path is all /// planar curves. @@ -1857,10 +2205,10 @@ const MAX_INSTANCES: usize = 1000; /// The placements (column-major matrices) of a pattern's copies, the original excluded. pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind) -> Result> { let (vals, _) = doc.param_values(); - pattern_transforms(&vals, st, p) + pattern_transforms(doc, &vals, st, 0, p) } -fn pattern_transforms(vals: &BTreeMap, st: &ModelState, p: &crate::PatternKind) -> Result> { +fn pattern_transforms(doc: &Document, vals: &BTreeMap, st: &ModelState, component: u64, p: &crate::PatternKind) -> Result> { let count = |e: &str| -> Result { let n = val(vals, e, Kind::Unitless)?.round(); if !(1.0..=MAX_INSTANCES as f64).contains(&n) { @@ -1895,6 +2243,8 @@ fn pattern_transforms(vals: &BTreeMap, st: &ModelState, p: &crate let n = count(c)?; let d = val(vals, spacing, Kind::Length)?; let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("path sketch {path_sketch}")))?; + let mapped = path_in_frame(doc, ps, component)?; + let ps = mapped.as_ref(); let mut segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?; if *flip { segs = segs.iter().rev().map(reverse_seg).collect(); @@ -2060,7 +2410,7 @@ fn replay( } } } else { - let tools = feature_tools(vals, src, st)?; + let tools = feature_tools(doc, vals, src, st)?; for m in mats { for t in &tools { copies.push(kernel::transform_matrix(t, *m)?); @@ -2068,7 +2418,14 @@ fn replay( } } // New bodies from a pattern join their source when they touch it (as one feature would). - apply_op(st, f, copies, op, &targets)?; + if f.body_names.is_empty() { + apply_op(st, f, copies, op, &targets)?; + } else { + let start = st.bodies.iter().filter(|b| b.feature == f.id).count(); + let mut named = f.clone(); + named.body_names = f.body_names.iter().skip(start).cloned().collect(); + apply_op(st, &named, copies, op, &targets)?; + } } Ok(()) } @@ -2081,7 +2438,7 @@ mod surface; /// Height above `plane` (along its normal) of the object at `p` as seen from `centre`: the face /// there if it is planar and parallel (its plane), else where a ray from `centre` along the /// normal meets the body there, else the point itself. -fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 { +pub fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 { let n = plane.normal(); let h = |q: Vec3| (q - plane.origin).dot(n); for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) { @@ -2308,6 +2665,7 @@ fn chamfer_with( fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { match &f.kind { + FeatureKind::BodyTransfer { bodies, from, to, transform } => crate::body_transfer::evaluate(doc, st, bodies, *from, *to, *transform), FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetFlange { .. } @@ -2329,13 +2687,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Align { .. } | FeatureKind::Remove { .. } => more::eval(doc, vals, f, st), FeatureKind::Sketch { plane, sketch } => { - let fst = state_in_frame(doc, st, f.component); - let (plane, face_warning) = match plane { - PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()), - other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None), + let anchor = crate::face_anchor::plane_face_anchor(plane); + let fst = match anchor { + Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?, + None => state_in_frame(doc, st, f.component), + }; + let (plane, face_warning) = match (anchor, plane) { + (Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None), + (None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()), + (None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None), }; let mut sk = sketch.clone(); - let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk); + let mut warns = match anchor { + Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor), + None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk), + }; drop(fst); sk.refresh_offsets()?; if let Some(w) = face_warning { @@ -2343,7 +2709,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } doc.apply_dimension_values(vals, &mut sk)?; let mut report = solve(&mut sk); - if !sk.offsets.is_empty() { + if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() { // Source dimensions may have moved curves during the solve. sk.refresh_offsets()?; report = solve(&mut sk); @@ -2354,6 +2720,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: if !warns.is_empty() { *warning = Some(warns.join("; ")); } + sk.validate_periodic_splines()?; let profiles = find_profiles(&sk); st.sketches.retain(|s| s.feature != f.id); st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }); @@ -2365,16 +2732,26 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Loft { operation, targets, .. } | FeatureKind::Coil { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => { - let tools = feature_tools(vals, f, st)?; + let tools = feature_tools(doc, vals, f, st)?; // Cuts through sheet metal become cut-outs of its flat pattern. - if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { + if matches!(operation, Operation::Cut | Operation::Intersect) && 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(()); @@ -2386,7 +2763,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } FeatureKind::Hole { thread, diameter, .. } => { let (spots, dir) = hole_spots(st, f)?; - let tools = feature_tools(vals, f, st)?; + let tools = feature_tools(doc, vals, f, st)?; apply_op(st, f, tools, Operation::Cut, &[])?; if let Some(d) = thread { // A point on each hole's wall, a little below the entry. @@ -2587,17 +2964,19 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: Ok(()) } FeatureKind::Pattern { features, pattern, bodies } => { - let mats = pattern_transforms(vals, st, pattern)?; + let mats = pattern_transforms(doc, vals, st, f.component, pattern)?; if bodies.is_empty() { return replay(doc, vals, f, st, features, &mats, warning); } // Bodies: each copy is a new body named after its source. + let mut output = 0; for n in bodies { let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; for m in &mats { let copy = kernel::transform_matrix(&mb.body, *m)?; - let name = unique_body_name(st, &mb.name); + let name = copy_body_name(st, f, output, &mb.name); st.bodies.push(ModelBody::new(name, copy, f.id)); + output += 1; } } Ok(()) @@ -2608,6 +2987,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)], warning); } let m = mirror_matrix(&pl); + let mut output = 0; for n in bodies { let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; let Some(mb) = st.bodies.get(i).cloned() else { continue }; @@ -2621,13 +3001,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: *slot = ModelBody::new(mb.name.clone(), p, mb.feature); } } else { - let name = unique_body_name(st, &mb.name); + let name = copy_body_name(st, f, output, &mb.name); st.bodies.push(ModelBody::new(name, p, f.id)); + output += 1; } } } else { - let name = unique_body_name(st, &mb.name); + let name = copy_body_name(st, f, output, &mb.name); st.bodies.push(ModelBody::new(name, mirrored, f.id)); + output += 1; } } Ok(()) @@ -2702,6 +3084,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); let corner = point(vals, corner)?; let b = kernel::box_solid(corner, corner + s)?; + let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b }; apply_op(st, f, vec![b], *operation, &[]) } FeatureKind::Cylinder { base, axis, radius, height, operation } => { @@ -2823,18 +3206,19 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: Ok(()) } FeatureKind::MeshImport { meshes, .. } => { - for (name, b) in mesh_bodies(meshes)? { - let name = unique_body_name(st, &name); + for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() { + let name = copy_body_name(st, f, k, &name); st.bodies.push(ModelBody::new(name, b, f.id)); } Ok(()) } FeatureKind::Import { step, .. } => { let imp = kernel::step_import_shared(step)?; + let digest = crate::config::payload_digest(step); for (k, b) in imp.bodies.iter().enumerate() { let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name); let name = unique_body_name(st, base); - st.bodies.push(ModelBody::new(name, b.body.clone(), f.id)); + st.bodies.push(ModelBody::new(name, b.body.clone().with_import_certificate(&digest, f.id)?, f.id)); } if !imp.warnings.is_empty() { let n = imp.warnings.len(); @@ -2873,22 +3257,36 @@ 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, } }; - let body_m: Vec = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect(); + let body_m: Vec = st + .bodies + .iter() + .map(|b| { + let m = rel(doc.model_body_component(b), None); + doc.model_body_offset(b).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m) + }) + .collect(); let sketch_m: Vec = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect(); - if body_m.iter().chain(&sketch_m).all(crate::is_identity) { + let world_wires_need_mapping = !crate::is_identity(&inv) + && st.sketches.iter().any(|sketch| sketch.sketch.wires.iter().any(|wire| wire.space == solvecraft_sketch::WireSpace::World)); + if body_m.iter().chain(&sketch_m).all(crate::is_identity) && !world_wires_need_mapping { return Cow::Borrowed(st); } let mut out = st.clone(); @@ -2900,6 +3298,10 @@ 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.transferred = b.transferred; + 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; } @@ -2910,6 +3312,10 @@ pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std: { s.plane = np; } + for wire in &mut s.sketch.wires { + let matrix = if wire.space == solvecraft_sketch::WireSpace::Component { m } else { &inv }; + map_wire(wire, matrix); + } } Cow::Owned(out) } @@ -2938,17 +3344,19 @@ 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()) }; + for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == comp) { + 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 bm = doc.model_body_offset(b).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.transferred = b.transferred; + 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)); @@ -2963,12 +3371,22 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { s2.plane = np; } } + for wire in &mut s2.sketch.wires { + if wire.space == solvecraft_sketch::WireSpace::Component { + map_wire(wire, &m); + } + } out.sketches.push(s2); } - for t in st.threads.iter().filter(|t| comp_of_feature(t.feature) == comp) { + for t in st + .threads + .iter() + .filter(|t| st.body(&t.body).map(|body| doc.model_body_component(body)).unwrap_or_else(|| comp_of_feature(t.feature)) == comp) + { let mut t2 = t.clone(); - t2.axis_point = crate::apply_point(&m, t.axis_point); - t2.axis = crate::apply_vector(&m, t.axis); + let frame = st.body(&t.body).and_then(|body| doc.model_body_offset(body)).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m); + t2.axis_point = crate::apply_point(&frame, t.axis_point); + t2.axis = crate::apply_vector(&frame, t.axis); out.threads.push(t2); } } diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 84b30e53..e6d6add3 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -2,18 +2,66 @@ //! kernel's extrude, boolean, offset and transform operations). use super::*; +use crate::EmbossFace; use crate::apply_point; +use crate::sheet; use solvecraft_geom::{Loop2, Seg2}; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { match &f.kind { - FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => { + FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; let regions = select_profiles(ss, profiles)?; let d = val(vals, depth, Kind::Length)?; if !(d > 0.0) { return Err(DocError::Invalid("the emboss depth must be positive".into())); } + if let Some(face) = face { + if !targets.is_empty() && (targets.len() != 1 || targets.first() != Some(&face.body)) { + return Err(DocError::Invalid("cylinder emboss must keep its selected target body".into())); + } + let i = + st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("emboss body `{}`", face.body)))?; + let mb = st.bodies.get(i).cloned().ok_or_else(|| DocError::Invalid("emboss body".into()))?; + let body_frame = face.body_frame(doc, &mb)?; + let inverse = crate::mat_inverse(&body_frame).ok_or_else(|| DocError::Invalid("invalid cylinder body frame".into()))?; + let sketch_owner = doc.feature(ss.feature).map(|f| f.component).unwrap_or(0); + let author_frame = EmbossFace::component_frame(doc, f.component, &face.author_path)?; + let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, &mb)?; + let sketch_transform = crate::mat_mul(&inverse, &sketch_frame); + let plane = Plane::new( + crate::apply_point(&sketch_transform, ss.plane.origin), + crate::apply_vector(&sketch_transform, ss.plane.x), + crate::apply_vector(&sketch_transform, ss.plane.y), + ) + .ok_or_else(|| DocError::Invalid("invalid cylinder sketch frame".into()))?; + let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at); + let name = f + .face_names + .first() + .filter(|name| !name.is_empty()) + .ok_or_else(|| DocError::Invalid("selected cylinder face has no persistent identity".into()))?; + { + let mut scoped = ModelState::default(); + scoped.bodies.push(mb.clone()); + let (picked, split) = crate::naming::point_on_face(&scoped, name, at) + .ok_or_else(|| DocError::Invalid("selected cylinder face no longer exists".into()))?; + if split { + return Err(DocError::Invalid("selected cylinder face was split".into())); + } + let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked) + .ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?; + if crate::naming::face_names(&mb).get(index as usize) != Some(name) { + return Err(DocError::Invalid("selected cylinder face is ambiguous".into())); + } + at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?; + } + let result = kernel::emboss_cylinder_at(&mb.body, at, &plane, ®ions, if *deboss { -d } else { d })?; + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::new(mb.name, result, mb.feature); + } + return Ok(()); + } let (a, b) = if *deboss { (-d, 0.0) } else { (0.0, d) }; let tools = kernel::extrude(&ss.plane, ®ions, a, b)?; if st.bodies.is_empty() { @@ -242,12 +290,22 @@ fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 { } /// Rebuild a sheet body's solid from its model. +fn replace_sheet_body(mb: &mut ModelBody, body: Body) { + let mut next = ModelBody::new(mb.name.clone(), body, mb.feature); + next.component = mb.component; + next.transferred = mb.transferred; + next.source_name = mb.source_name.clone(); + next.source_transform = mb.source_transform; + next.imported = mb.imported.clone(); + *mb = next; +} + fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> { let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?; let solid = sheet.solid()?; let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?; if let Some(mb) = st.bodies.get_mut(i) { - *mb = ModelBody::new(mb.name.clone(), solid, mb.feature); + replace_sheet_body(mb, solid); } Ok(()) } @@ -466,7 +524,7 @@ pub(super) fn sheet_eval( sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect(); let solid = sh.solid()?; if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) { - *b = ModelBody::new(b.name.clone(), solid, b.feature); + replace_sheet_body(b, solid); } st.sheets.retain(|s| &s.body != body); st.sheets.push(sh); @@ -481,14 +539,15 @@ fn poly_area(p: &[Vec2]) -> f64 { (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 } -/// An extrude cut through sheet metal: the profiles become cut-outs of the flat pattern -/// (parallel to a panel, through its thickness). Returns the sheet bodies handled. +/// Exact prismatic machining, retained in sheet material/depth metadata. pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result> { - let FeatureKind::Extrude { sketch, profiles, .. } = &f.kind else { return Ok(Vec::new()) }; + let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) }; + if extent.taper.is_some() { + return Ok(Vec::new()); + } let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) }; let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let n = ss.plane.normal(); - // How far the tools reach along the sketch normal. let mut span = (f64::MAX, f64::MIN); for t in tools { for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions { @@ -496,64 +555,127 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target span = (span.0.min(d), span.1.max(d)); } } - let mut handled = Vec::new(); - 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 targets = if targets.is_empty() { &f.participants } else { targets }; + let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); + let mut plans = Vec::new(); + let mut work = sheet::FlatWork::new(); + for original in &st.sheets { + let eligible = all + || if targets.is_empty() { + st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id)) + } else { + targets.contains(&original.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() })] - } - } else { - sh.parallel_panels(&ss.plane) + let Some(mb) = st.body(&original.body) else { + continue; }; - let mut added = Vec::new(); - for (pi, panel) in candidates { - // The panel's faces along the sketch normal must both be inside the cut. - let w = sh.world(pi); - let z0 = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n); - let z1 = (apply_point(&w, Vec3::Z * sh.t) - ss.plane.origin).dot(n); - 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)); - if overlaps && !added.iter().any(|x: &Region2| x == &flat) { - added.push(flat); + let mut sh = original.clone(); + if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) { + super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); + continue; + } + if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? { + plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); + continue; + } + if let Some(bends) = sh.unmodeled_solid(&ss.plane)? { + for tool in tools { + if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() { + return Err(DocError::Invalid( + "this machining intersects a folded bend or a nonparallel panel; unfold the sheet before machining that material".into(), + )); } } } - if added.is_empty() { - continue; + let candidates = if sh.flat { + if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() } + } else { + sh.material_panels(Some(&ss.plane), &mut work)? + }; + let mut modeled = false; + for (pi, panel) in candidates { + let w = if sh.flat { sh.world(None) } else { sh.world(pi) }; + let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n); + let sign = crate::apply_vector(&w, Vec3::Z).dot(n); + if sign.abs() < 1e-6 { + continue; + } + let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign); + let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t)); + if *operation == Operation::Intersect { + if z1 <= z0 { + sh.holes.push(panel); + modeled = true; + continue; + } + if z0 > 1e-7 || z1 < sh.t - 1e-7 { + super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + continue; + } + let mapped = regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::>>()?; + let tool = sheet::material_union(mapped, &mut work)?; + let retained = sheet::material_boolean(std::slice::from_ref(&panel), &tool, kernel::BoolOp::Intersect, &mut work)?; + let removed = sheet::material_boolean(&[panel], &retained, kernel::BoolOp::Cut, &mut work)?; + sh.holes.extend(removed); + modeled = true; + } else if z1 > z0 { + for r in ®ions { + let flat = sh.region_to_flat(pi, &ss.plane, r)?; + let clipped = + sheet::material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?; + if !clipped.is_empty() && z0 <= 1e-7 && z1 >= sh.t - 1e-7 { + // Keep a single original tool boundary across structural seams. + // Re-clipping notch polygons creates coincident seam endpoints. + if !sh.holes.contains(&flat) { + sh.holes.push(flat); + } + modeled = true; + } else { + for region in clipped { + sh.pockets.push(sheet::SheetPocket { region, z0, z1 }); + modeled = true; + } + } + } + } } - if let Some(s) = st.sheets.get_mut(i) { - s.holes.extend(added); + if !modeled { + super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); + } else if sh.flat_material()?.is_empty() { + // Empty exact material removes geometry and metadata together. Budget or + // topology errors propagate; they are never mistaken for an empty blank. + plans.push((sh.body.clone(), None)); + } else { + let body = sh.solid()?; + plans.push((sh.body.clone(), Some((sh, body)))); + } + } + let handled = plans.iter().map(|(name, _)| name.clone()).collect(); + for (name, result) in plans { + match result { + Some((sh, body)) => { + if let Some(mb) = st.bodies.iter_mut().find(|b| b.name == name) { + replace_sheet_body(mb, body); + } + if let Some(target) = st.sheets.iter_mut().find(|s| s.body == name) { + *target = sh; + } + } + None => { + st.bodies.retain(|b| b.name != name); + st.sheets.retain(|s| s.body != name); + st.threads.retain(|t| t.body != name); + } } - rebuild_sheet(st, i)?; - handled.push(sh.body.clone()); } 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/face_anchor.rs b/crates/doc/src/face_anchor.rs new file mode 100644 index 00000000..6dde99d2 --- /dev/null +++ b/crates/doc/src/face_anchor.rs @@ -0,0 +1,561 @@ +//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback. +use crate::config::ConfigFaceTarget; +use crate::{DocError, Document, EmbossFace, FeatureKind, Model, ModelBody, ModelState, PlaneRef, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use solvecraft_geom::Plane; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FaceAnchor { + pub target: ConfigFaceTarget, + pub component: u64, + pub path: Vec, + pub author_path: Vec, + /// Origin and x direction in the exact canonical face plane's coordinates. + pub coordinates: [f64; 2], + pub x_axis: [f64; 2], +} + +impl FaceAnchor { + pub fn remap_target( + &mut self, + features: &std::collections::BTreeMap, + bodies: &std::collections::BTreeMap, + ) -> Result<()> { + if bodies.contains_key(&self.target.body) && !features.contains_key(&self.target.origin_feature) { + return Err(DocError::Invalid("cannot clone an attached face without its certified producer".into())); + } + self.target.name = crate::remap_topology(&self.target.name, features, bodies); + if let Some(body) = bodies.get(&self.target.body) { + self.target.body = body.clone(); + } + if let Some(feature) = features.get(&self.target.origin_feature) { + self.target.origin_feature = *feature; + } + if let Some(certificate) = &mut self.target.certificate { + certificate.remap_features(features); + } + self.validate() + } + pub fn validate(&self) -> Result<()> { + self.target.validate()?; + if self.target.certificate.is_none() + || self.path.len() > 64 + || self.author_path.len() > 64 + || self.path.iter().chain(&self.author_path).any(|id| *id == 0) + || (self.component == 0 && !self.path.is_empty()) + || (self.component != 0 && self.path.is_empty()) + || self.coordinates.iter().any(|value| !value.is_finite() || value.abs() > 1e9) + || self.x_axis.iter().any(|value| !value.is_finite()) + || (self.x_axis[0].hypot(self.x_axis[1]) - 1.).abs() > 1e-6 + { + return Err(DocError::Invalid("invalid exact face attachment".into())); + } + Ok(()) + } + pub fn author_frame(&self, doc: &Document, author: u64) -> Result { + if author != 0 && self.author_path.is_empty() { + return Err(DocError::Invalid( + "attached sketch requires an explicit author occurrence path; activate a placement or choose a fixed plane".into(), + )); + } + EmbossFace::component_frame(doc, author, &self.author_path) + } + pub fn source_to_author(&self, doc: &Document, author: u64, body: &ModelBody) -> Result { + self.validate()?; + if doc.model_body_component(body) != self.component { + return Err(DocError::Invalid("attached face's component ownership changed; reattach explicitly".into())); + } + let inverse = crate::mat_inverse(&self.author_frame(doc, author)?) + .ok_or_else(|| DocError::Invalid("attached sketch author frame is invalid".into()))?; + let source = + EmbossFace { body: self.target.body.clone(), at: self.target.point, path: self.path.clone(), author_path: self.author_path.clone() }; + Ok(crate::mat_mul(&inverse, &source.body_frame(doc, body)?)) + } +} + +pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> { + match plane { + PlaneRef::Face { anchor, .. } => anchor.as_deref(), + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane_face_anchor(base), + _ => None, + } +} + +fn has_face_reference(plane: &PlaneRef) -> bool { + match plane { + PlaneRef::Face { .. } => true, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => has_face_reference(base), + _ => false, + } +} + +impl Document { + pub fn resolve_attached_plane( + &self, + vals: &std::collections::BTreeMap, + plane: &PlaneRef, + author: u64, + state: &ModelState, + framed: &ModelState, + ) -> Result { + fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<()> { + if depth > 64 { + return Err(DocError::Invalid("attached plane nesting exceeds budget".into())); + } + match plane { + PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? }, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => replace(doc, base, author, state, depth + 1)?, + _ => {} + } + Ok(()) + } + let mut resolved = plane.clone(); + replace(self, &mut resolved, author, state, 0)?; + self.resolve_plane_in(vals, &resolved, 0, Some(framed)) + } +} + +/// All status planes are world-space. A lost plane is a repair guide only. +#[derive(Clone, Debug)] +pub enum FaceAttachmentStatus { + Fixed { plane: Option }, + Legacy { plane: Option }, + Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec }, + Lost { message: String, repair_plane: Option }, +} + +fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result { + Plane::new(crate::apply_point(matrix, plane.origin), crate::apply_vector(matrix, plane.x), crate::apply_vector(matrix, plane.y)) + .ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into())) +} + +impl Document { + /// Capture a known selected face in the definition model, without another nearest pick. + pub fn capture_face_anchor( + &self, + body: &ModelBody, + index: usize, + picked: &Plane, + target_path: &[u64], + author: u64, + author_path: &[u64], + ) -> Result { + let source = body + .body + .face_plane(index) + .ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?; + let target = self.strong_face_target(body, index, source.origin)?; + if target.certificate.is_none() { + return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into())); + } + self.configuration_face_indices(body, &target)?; + let mut anchor = FaceAnchor { + target, + component: self.model_body_component(body), + path: target_path.to_vec(), + author_path: author_path.to_vec(), + coordinates: [0., 0.], + x_axis: [1., 0.], + }; + let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &source)?; + if picked.normal().dot(canonical.normal()) < 1. - 1e-7 || (picked.origin - canonical.origin).dot(canonical.normal()).abs() > 1e-7 { + return Err(DocError::Invalid("picked plane does not match its exact source face".into())); + } + let delta = picked.origin - canonical.origin; + anchor.coordinates = [delta.dot(canonical.x), delta.dot(canonical.y)]; + anchor.x_axis = [picked.x.dot(canonical.x), picked.x.dot(canonical.y)]; + anchor.validate()?; + Ok(anchor) + } + + pub fn resolve_face_anchor(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result { + anchor.validate()?; + let body = + state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?; + let indices = self.configuration_face_indices(body, &anchor.target)?; + let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?; + let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?; + for index in indices.iter().skip(1) { + let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?; + if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 { + return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())); + } + } + let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &plane)?; + let origin = canonical.origin + canonical.x * anchor.coordinates[0] + canonical.y * anchor.coordinates[1]; + let x = canonical.x * anchor.x_axis[0] + canonical.y * anchor.x_axis[1]; + Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into())) + } + + pub fn sketch_face_attachment(&self, feature: u64, model: &Model) -> FaceAttachmentStatus { + let Some(feature) = self.feature(feature) else { + return FaceAttachmentStatus::Lost { message: "sketch no longer exists".into(), repair_plane: None }; + }; + let FeatureKind::Sketch { plane, .. } = &feature.kind else { + return FaceAttachmentStatus::Fixed { plane: None }; + }; + let Some(anchor) = plane_face_anchor(plane) else { + let path = self.first_path(feature.component); + let unique = path.iter().all(|id| { + self.occurrences + .iter() + .find(|o| o.id == *id) + .is_some_and(|selected| self.occurrences.iter().filter(|o| o.component == selected.component).count() == 1) + }); + let world = unique.then(|| EmbossFace::component_frame(self, feature.component, &path).ok()).flatten(); + let current = model.state().sketch(feature.id).map(|sketch| sketch.plane).or_else(|| { + let (vals, _) = self.param_values(); + self.resolve_plane(&vals, plane, 0).ok() + }); + let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok())); + return if has_face_reference(plane) { + FaceAttachmentStatus::Legacy { plane: current } + } else { + FaceAttachmentStatus::Fixed { plane: current } + }; + }; + let (vals, _) = self.param_values(); + let picked = self.resolve_plane(&vals, plane, 0).ok(); + let world = anchor.author_frame(self, feature.component); + let repair_plane = world.as_ref().ok().and_then(|matrix| picked.as_ref().and_then(|picked| map_plane(matrix, picked).ok())); + let state = model.state_before(feature.id); + match crate::state_in_face_anchor_frame(self, &state, anchor, feature.component) + .and_then(|framed| self.resolve_attached_plane(&vals, plane, feature.component, &state, &framed)) + .and_then(|plane| map_plane(&world?, &plane)) + { + Ok(plane) => FaceAttachmentStatus::Healthy { plane, target: anchor.target.clone(), path: anchor.path.clone() }, + Err(error) => FaceAttachmentStatus::Lost { message: format!("lost face attachment on `{}`: {error}", anchor.target.body), repair_plane }, + } + } +} + +pub fn validate_face_anchor_json(value: &Value) -> Result<()> { + let fields = value.as_object().ok_or_else(|| DocError::Invalid("face anchor must be an object".into()))?; + if fields.len() != 6 || fields.keys().any(|key| !["target", "component", "path", "author_path", "coordinates", "x_axis"].contains(&key.as_str())) + { + return Err(DocError::Invalid("unknown or missing face anchor fields".into())); + } + let target = fields.get("target").ok_or_else(|| DocError::Invalid("face anchor target missing".into()))?; + crate::config::validate_face_target_json(target)?; + if target.get("certificate").is_none_or(Value::is_null) { + return Err(DocError::Invalid("face anchor needs a source certificate".into())); + } + let component = + fields.get("component").and_then(Value::as_u64).ok_or_else(|| DocError::Invalid("face anchor component must be an integer".into()))?; + for key in ["path", "author_path"] { + if fields.get(key).and_then(Value::as_array).is_none_or(|path| { + path.len() > 64 + || path.iter().any(|id| id.as_u64().is_none_or(|id| id == 0)) + || (key == "path" && component == 0 && !path.is_empty()) + || (key == "path" && component != 0 && path.is_empty()) + }) { + return Err(DocError::Invalid("face anchor path is invalid or oversized".into())); + } + } + for key in ["coordinates", "x_axis"] { + let values = fields + .get(key) + .and_then(Value::as_array) + .filter(|values| values.len() == 2) + .ok_or_else(|| DocError::Invalid("face anchor vectors require exactly two values".into()))?; + if values.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) { + return Err(DocError::Invalid("face anchor vector is invalid".into())); + } + if key == "x_axis" && (values[0].as_f64().unwrap_or(0.).hypot(values[1].as_f64().unwrap_or(0.)) - 1.).abs() > 1e-6 { + return Err(DocError::Invalid("face anchor basis must be normalized".into())); + } + } + Ok(()) +} + +/// Walk reference-bearing JSON before typed decoding, excluding bulk geometry payloads. +pub fn validate_face_anchors_json(value: &Value) -> Result<()> { + let mut stack = vec![(value, 0usize)]; + let mut visited = 0usize; + while let Some((value, depth)) = stack.pop() { + visited += 1; + if visited > 2_000_000 || depth > 128 { + return Err(DocError::Invalid("face anchor structure exceeds work limits".into())); + } + match value { + Value::Object(fields) => { + if fields.get("type").and_then(Value::as_str) == Some("face") + && let Some(anchor) = fields.get("anchor") + && !anchor.is_null() + { + validate_face_anchor_json(anchor)?; + } + for (key, child) in fields { + if !["sketch", "meshes", "step", "configs", "appearances", "materials"].contains(&key.as_str()) + && matches!(child, Value::Array(_) | Value::Object(_)) + { + stack.push((child, depth + 1)); + } + } + } + Value::Array(values) => { + for child in values { + if matches!(child, Value::Array(_) | Value::Object(_)) { + stack.push((child, depth + 1)); + } + } + } + _ => {} + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use solvecraft_geom::Vec3; + fn fixture() -> (Document, ModelBody, usize, Plane) { + let mut doc = Document::default(); + let kind: FeatureKind = + serde_json::from_value(serde_json::json!({"type":"box","corner":[0,0,0],"length":"10","width":"10","height":"10"})).unwrap(); + let id = doc.add_feature(kind, Some("Base")).unwrap(); + let body = ModelBody::new( + "Base".into(), + solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(id).unwrap(), + id, + ); + let index = body + .body + .face_sources() + .unwrap() + .iter() + .position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. }))) + .unwrap(); + let canonical = body.body.face_plane(index).unwrap(); + let picked = Plane::new(canonical.origin + canonical.x * 2. + canonical.y * 3., canonical.y, -canonical.x).unwrap(); + (doc, body, index, picked) + } + #[test] + fn exact_anchor_preserves_picked_basis_and_follows_height_without_parallel_rescue() { + let (doc, body, index, picked) = fixture(); + let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap(); + let anchor: FaceAnchor = serde_json::from_str(&serde_json::to_string(&anchor).unwrap()).unwrap(); + assert!(anchor.author_frame(&doc, 1).is_err(), "strong author attachment cannot infer a first placement"); + let mut missing_source = anchor.clone(); + missing_source.component = 1; + assert!(missing_source.validate().is_err(), "strong source attachment needs an explicit path"); + assert!(validate_face_anchor_json(&serde_json::to_value(missing_source).unwrap()).is_err()); + let mut state = ModelState::default(); + state.bodies.push(body); + let initial = doc.resolve_face_anchor(&anchor, 0, &state).unwrap(); + assert!((initial.origin - picked.origin).len() < 1e-9); + assert!((initial.x - picked.x).len() < 1e-9); + state.bodies[0] = ModelBody::new( + "Base".into(), + solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 20.)) + .unwrap() + .with_box_certificate(anchor.target.origin_feature) + .unwrap(), + anchor.target.origin_feature, + ); + let changed = doc.resolve_face_anchor(&anchor, 0, &state).unwrap(); + assert!((changed.origin.z - 20.).abs() < 1e-9); + assert!((changed.origin.x - initial.origin.x).abs() < 1e-9); + state.bodies[0] = ModelBody::new( + "Control".into(), + solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.)) + .unwrap() + .with_box_certificate(anchor.target.origin_feature) + .unwrap(), + anchor.target.origin_feature, + ); + assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err()); + state.bodies[0].name = "Base".into(); + state.bodies[0].body = solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.)) + .unwrap() + .with_box_certificate(anchor.target.origin_feature + 1) + .unwrap(); + assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err(), "reused name cannot replace the producer"); + } + #[test] + fn nested_anchor_offset_tracks_source_and_raw_budgets_precede_decode() { + let (doc, body, index, picked) = fixture(); + let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap(); + let plane = PlaneRef::Offset { + base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) }), + distance: "2".into(), + }; + let mut state = ModelState::default(); + state.bodies.push(body); + let (vals, _) = doc.param_values(); + let result = doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).unwrap(); + assert!((result.origin.z - 12.).abs() < 1e-9); + state.bodies.clear(); + assert!(doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).is_err()); + let mut raw = serde_json::to_value(anchor).unwrap(); + assert!(validate_face_anchor_json(&raw).is_ok()); + raw["path"] = serde_json::json!([1]); + assert!(validate_face_anchor_json(&raw).is_err()); + raw["path"] = serde_json::json!([]); + raw["target"]["name"] = Value::String("x".repeat(1_000_000)); + assert!(validate_face_anchor_json(&raw).is_err()); + } + #[test] + fn extrude_cap_profile_reordering_never_retargets_an_ordinal_body() { + use solvecraft_geom::Vec2; + let mut sketch = solvecraft_sketch::Sketch::new(); + for offset in [0., 30.] { + let points: Vec<_> = [(0., 0.), (10., 0.), (10., 10.), (0., 10.)] + .iter() + .map(|(x, y)| sketch.add_point(Vec2::new(x + offset, *y), None).unwrap()) + .collect(); + for edge in 0..4 { + sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap(); + } + } + let mut doc = Document::default(); + let source = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let extent: crate::Extent = serde_json::from_value(serde_json::json!({"distance":"10"})).unwrap(); + let producer = doc + .add_feature( + FeatureKind::Extrude { + sketch: source, + profiles: crate::ProfileSel::Indices { indices: vec![0, 1] }, + extent, + operation: crate::Operation::NewBody, + targets: vec![], + }, + None, + ) + .unwrap(); + let mut model = Model::new(); + model.evaluate(&doc); + let state = model.state(); + assert_eq!(state.bodies.len(), 2); + let solved = state.sketch(source).unwrap(); + let regions = crate::select_profiles(solved, &crate::ProfileSel::All).unwrap(); + let raw = solvecraft_kernel::extrude(&solved.plane, ®ions, 0., 10.).unwrap(); + assert!( + crate::config::extrude_cap_bindings(solved, &raw, 0).is_none(), + "exhausted certification work must leave the complete producer uncertified" + ); + let bindings = crate::config::extrude_cap_bindings(solved, &raw, 10_000_000).unwrap(); + assert_eq!(bindings.iter().flatten().flatten().count(), 4, "two profiles each retain both exact caps within budget"); + let mut duplicate = raw.clone(); + duplicate.extend(raw); + let ambiguous = crate::config::extrude_cap_bindings(solved, &duplicate, 10_000_000).unwrap(); + assert!(ambiguous.iter().flatten().all(Option::is_none), "duplicated profile identities cannot stamp ambiguous caps"); + let body = &state.bodies[0]; + let index = body + .body + .face_sources() + .unwrap() + .iter() + .position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { high: true, .. }))) + .unwrap(); + let picked = body.body.face_plane(index).unwrap(); + let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap(); + let bounds = body.mesh().bounds(); + let FeatureKind::Extrude { profiles, .. } = &mut doc.feature_mut(producer).unwrap().kind else { + panic!("fixture must retain its extrude feature") + }; + *profiles = crate::ProfileSel::Indices { indices: vec![1, 0] }; + model.evaluate(&doc); + let changed = model.state(); + assert_eq!(changed.bodies.len(), 2); + if doc.resolve_face_anchor(&anchor, 0, &changed).is_ok() { + let current = changed.body(&anchor.target.body).unwrap().mesh().bounds(); + assert!((current.min.x - bounds.min.x).abs() < 1e-9, "a same-role cap of the other profile must never replace the source"); + } + } + #[test] + fn wrapped_legacy_face_is_reported_as_legacy_without_inferred_upgrade() { + let (mut doc, _, _, picked) = fixture(); + let id = doc + .add_feature( + FeatureKind::Sketch { + plane: PlaneRef::Offset { + base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: None }), + distance: "2".into(), + }, + sketch: solvecraft_sketch::Sketch::new(), + }, + None, + ) + .unwrap(); + let mut model = Model::new(); + model.evaluate(&doc); + let FaceAttachmentStatus::Legacy { plane: Some(plane) } = doc.sketch_face_attachment(id, &model) else { + panic!("wrapped legacy attachment must retain its classification and fixed-plane escape"); + }; + assert!((plane.origin.z - 12.).abs() < 1e-9); + let FeatureKind::Sketch { plane, .. } = &doc.feature(id).unwrap().kind else { panic!("fixture must retain its sketch feature") }; + assert!(plane_face_anchor(plane).is_none()); + } + #[test] + fn warm_root_body_offset_edit_matches_cold_attached_sketch_and_downstream_bounds() { + use solvecraft_geom::Vec2; + let (mut doc, _, _, _) = fixture(); + let mut warm = Model::new(); + warm.evaluate(&doc); + let source = warm.state(); + let body = &source.bodies[0]; + let index = body + .body + .face_sources() + .unwrap() + .iter() + .position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. }))) + .unwrap(); + let picked = body.body.face_plane(index).unwrap(); + let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap(); + let source_name = body.name.clone(); + let mut sketch = solvecraft_sketch::Sketch::new(); + let points: Vec<_> = + [(2., 2.), (4., 2.), (4., 4.), (2., 4.)].iter().map(|(x, y)| sketch.add_point(Vec2::new(*x, *y), None).unwrap()).collect(); + for edge in 0..4 { + sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap(); + } + let sk = doc + .add_feature( + FeatureKind::Sketch { + plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor)) }, + sketch, + }, + None, + ) + .unwrap(); + let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap(); + let boss = doc + .add_feature( + FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] }, + Some("Boss"), + ) + .unwrap(); + warm.evaluate(&doc); + let initial = warm.state(); + let before = initial.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds(); + let mut offset = crate::IDENTITY; + offset[3][0] = 5.; + offset[3][2] = 3.; + doc.body_offsets.insert(source_name, offset); + warm.evaluate(&doc); + let mut cold = Model::new(); + cold.evaluate(&doc); + let warm_state = warm.state(); + let cold_state = cold.state(); + let after = warm_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds(); + let rebuilt = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds(); + assert!( + (after.min - before.min - Vec3::new(5., 0., 3.)).len() < 1e-7, + "before={before:?}, warm={after:?}, cold={rebuilt:?}, warm_plane={:?}, cold_plane={:?}", + warm_state.sketch(sk).map(|sk| sk.plane), + cold_state.sketch(sk).map(|sk| sk.plane) + ); + assert!((after.min - rebuilt.min).len() < 1e-7); + assert!((after.max - rebuilt.max).len() < 1e-7); + let FaceAttachmentStatus::Healthy { plane, .. } = doc.sketch_face_attachment(sk, &warm) else { + panic!("offset source must remain attached"); + }; + assert!((plane.origin - warm_state.sketch(sk).unwrap().plane.origin).len() < 1e-9); + assert!((warm_state.sketch(sk).unwrap().plane.origin - cold_state.sketch(sk).unwrap().plane.origin).len() < 1e-9); + } +} diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 4b94a488..e60ec16f 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -20,13 +20,26 @@ //! "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. +//! - 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. +//! - 9: configured component/semantic-face styles, associative free-form offsets and +//! explicit cylindrical emboss targets. Existing version 8 geometry and rules are +//! preserved; new fields use defaults rather than guessing a target or an association. +//! - 10: timeline body transfers, explicit 3D wire coordinate spaces and certified face +//! bindings. Legacy ownership maps remain unchanged. Retained wire associations determine +//! their space; saved coordinates are never rewritten during migration. +//! - 11: durable sketch face anchors, explicitly periodic fit splines and versioned revolve +//! conventions. Absent markers retain legacy attachment and interpolation behavior. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 6; +pub const FORMAT: u32 = 11; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -39,7 +52,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; 10] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| 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, @@ -130,10 +143,35 @@ pub fn read(s: &str) -> Result { ))); } check_numbers(&v, 0)?; + crate::config::validate_rules_json(&v)?; + crate::config::validate_styles_json(&v)?; + crate::face_anchor::validate_face_anchors_json(&v)?; + validate_versioned_geometry(&v)?; + validate_emboss_targets(&v)?; + validate_freeform_offsets(&v)?; + let legacy_wires = validate_wires(&v, version)?; + validate_body_transfers(&v)?; for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) { step(&mut v)?; } let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?; + let mut legacy_by_feature = std::collections::BTreeMap::>::new(); + for (feature, wire) in legacy_wires { + legacy_by_feature.entry(feature).or_default().push(wire); + } + for (feature_index, wire_indices) in legacy_by_feature { + if let Some(crate::Feature { kind: crate::FeatureKind::Sketch { sketch, .. }, .. }) = d.features.get_mut(feature_index) { + let mut spaces = std::collections::BTreeMap::new(); + for link in &sketch.links { + spaces.entry(link.id.as_str()).or_insert_with(|| solvecraft_sketch::WireSpace::from_legacy_link(link)); + } + for wire_index in wire_indices { + if let Some(wire) = sketch.wires.get_mut(wire_index) { + wire.space = wire.link.as_deref().and_then(|id| spaces.get(id)).copied().unwrap_or_default(); + } + } + } + } if d.features.len() > crate::document::MAX_FEATURES || d.params.len() > crate::document::MAX_PARAMS || d.components.len() > crate::document::MAX_FEATURES @@ -145,12 +183,327 @@ pub fn read(s: &str) -> Result { return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name))); } } + let component_ids: std::collections::BTreeSet<_> = d.components.iter().map(|component| component.id).chain(std::iter::once(0)).collect(); + for feature in &d.features { + if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind { + sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; + sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; + sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; + } + if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind + && [from, to].into_iter().any(|id| !component_ids.contains(id)) + { + return Err(DocError::Invalid("damaged design: body transfer references an unknown component".into())); + } + if let crate::FeatureKind::BodyTransfer { to, .. } = &feature.kind + && feature.component != *to + { + return Err(DocError::Invalid("damaged design: body transfer owner must match its destination".into())); + } + } // Fill in what older designs (and hand-edited files) may lack; a no-op otherwise. normalise(&mut d)?; d.format = format_string(FORMAT); Ok(d) } +/// Bound arrays before typed allocation; only old files with absent markers need migration. +fn validate_wires(value: &Value, version: u32) -> Result> { + let invalid = || DocError::Invalid("damaged design: 3D curve arrays, ids or coordinate spaces are invalid".into()); + let mut legacy = Vec::new(); + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(legacy) }; + if features.len() > crate::document::MAX_FEATURES { + return Err(invalid()); + } + for (feature_index, feature) in features.iter().enumerate() { + if feature.get("type").and_then(Value::as_str) != Some("sketch") { + continue; + } + let Some(wires) = feature.get("sketch").and_then(|sketch| sketch.get("wires")) else { continue }; + let wires = wires.as_array().ok_or_else(invalid)?; + if wires.len() > 20_000 { + return Err(invalid()); + } + let mut ids = std::collections::BTreeSet::new(); + let mut work = 0usize; + for (wire_index, wire) in wires.iter().enumerate() { + let id = wire.get("id").and_then(Value::as_str).ok_or_else(invalid)?; + if id.is_empty() || id.len() > 512 || !ids.insert(id) { + return Err(invalid()); + } + if let Some(link) = wire.get("link").filter(|link| !link.is_null()) + && link.as_str().is_none_or(|link| link.is_empty() || link.len() > 512) + { + return Err(invalid()); + } + match wire.get("space") { + None if version < 10 => legacy.push((feature_index, wire_index)), + None => {} + Some(Value::String(space)) if matches!(space.as_str(), "world" | "component") => {} + _ => return Err(invalid()), + } + let pts = wire.get("pts").and_then(Value::as_array).ok_or_else(invalid)?; + let fit = match wire.get("fit") { + None => &[][..], + Some(fit) => fit.as_array().ok_or_else(invalid)?.as_slice(), + }; + work = work.saturating_add(pts.len()).saturating_add(fit.len()); + if work > 1_000_000 || pts.is_empty() || pts.len() > 100_000 || fit.len() > 500 || (pts.len() == 1 && fit.len() != 1) { + return Err(invalid()); + } + for point in pts.iter().chain(fit) { + let point = point.as_array().ok_or_else(invalid)?; + if point.len() != 3 || point.iter().any(|coordinate| coordinate.as_f64().is_none_or(|n| !n.is_finite() || n.abs() >= MAX_REAL)) { + return Err(invalid()); + } + } + } + } + Ok(legacy) +} + +fn validate_body_transfers(value: &Value) -> Result<()> { + let invalid = || DocError::Invalid("damaged design: body transfer needs bounded unique bodies, components and a rigid placement".into()); + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; + for feature in features { + if feature.get("type").and_then(Value::as_str) != Some("body_transfer") { + continue; + } + let bodies = feature.get("bodies").and_then(Value::as_array).ok_or_else(invalid)?; + if bodies.is_empty() || bodies.len() > 1000 { + return Err(invalid()); + } + let mut names = std::collections::BTreeSet::new(); + for body in bodies { + let body = body.as_str().ok_or_else(invalid)?; + if body.trim().is_empty() || body.len() > 512 || body.chars().any(char::is_control) || !names.insert(body) { + return Err(invalid()); + } + } + for key in ["from", "to"] { + if feature.get(key).and_then(Value::as_u64).is_none() { + return Err(invalid()); + } + } + let owner = match feature.get("component") { + None => 0, + Some(value) => value.as_u64().ok_or_else(invalid)?, + }; + if feature.get("to").and_then(Value::as_u64) != Some(owner) { + return Err(DocError::Invalid("damaged design: body transfer owner must match its destination".into())); + } + let matrix = feature.get("transform").and_then(Value::as_array).ok_or_else(invalid)?; + if matrix.len() != 4 { + return Err(invalid()); + } + let mut placement = [[0.0; 4]; 4]; + for (out, column) in placement.iter_mut().zip(matrix) { + let column = column.as_array().ok_or_else(invalid)?; + if column.len() != 4 { + return Err(invalid()); + } + for (out, number) in out.iter_mut().zip(column) { + *out = number.as_f64().ok_or_else(invalid)?; + } + } + if !crate::body_transfer::valid_transfer_matrix(&placement) { + return Err(invalid()); + } + } + Ok(()) +} + +/// Validate new explicit targets before typed decoding. Older planar features have none. +fn validate_emboss_targets(value: &Value) -> Result<()> { + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; + if features.len() > crate::document::MAX_FEATURES { + return Err(DocError::Invalid("document too large".into())); + } + for feature in features { + if feature.get("type").and_then(Value::as_str) != Some("emboss") { + continue; + } + let Some(target) = feature.get("face").filter(|target| !target.is_null()) else { continue }; + let invalid = || DocError::Invalid("damaged design: an emboss face needs a bounded body name and three finite coordinates".into()); + let object = target.as_object().ok_or_else(invalid)?; + if object.keys().any(|key| !matches!(key.as_str(), "body" | "at" | "path" | "author_path")) { + return Err(invalid()); + } + let body = object.get("body").and_then(Value::as_str).ok_or_else(invalid)?; + let point = object.get("at").and_then(Value::as_array).ok_or_else(invalid)?; + if body.trim().is_empty() + || body.len() > 512 + || body.chars().any(char::is_control) + || point.len() != 3 + || point.iter().any(|coordinate| coordinate.as_f64().is_none_or(|number| !number.is_finite() || number.abs() > MAX_REAL)) + { + return Err(invalid()); + } + for path in ["path", "author_path"].into_iter().filter_map(|key| object.get(key)) { + let path = path.as_array().ok_or_else(invalid)?; + if path.len() > 64 { + return Err(invalid()); + } + let mut ids = std::collections::BTreeSet::new(); + for id in path { + let id = id.as_u64().filter(|id| *id != 0).ok_or_else(invalid)?; + if !ids.insert(id) { + return Err(invalid()); + } + } + } + } + Ok(()) +} + +/// Bound explicitly versioned geometry before allocating typed spline point arrays. +fn validate_versioned_geometry(value: &Value) -> Result<()> { + let invalid = || DocError::Invalid("damaged design: invalid periodic spline or revolve convention".into()); + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; + for feature in features { + if feature.get("type").and_then(Value::as_str) == Some("revolve") + && let Some(convention) = feature.get("convention") + && !matches!(convention.as_str(), Some("legacy_axis_start" | "sketch_normal")) + { + return Err(invalid()); + } + if feature.get("type").and_then(Value::as_str) != Some("sketch") { + continue; + } + let Some(sketch) = feature.get("sketch") else { continue }; + if let Some(links) = sketch.get("links").and_then(Value::as_array) { + for link in links { + if link.get("auto_face").is_some_and(|marker| !marker.is_boolean()) { + return Err(invalid()); + } + } + } + let Some(curves) = sketch.get("curves").and_then(Value::as_array) else { continue }; + let points = sketch.get("points").and_then(Value::as_array); + let mut work = 0usize; + for curve in curves { + if curve.get("type").and_then(Value::as_str) != Some("spline") { + continue; + } + let Some(marker) = curve.get("periodic") else { continue }; + let periodic = marker.as_bool().ok_or_else(invalid)?; + if !periodic { + continue; + } + let indices = curve.get("pts").and_then(Value::as_array).ok_or_else(invalid)?; + work = work.saturating_add(indices.len()); + if !(4..=solvecraft_sketch::MAX_SPLINE_POINTS).contains(&indices.len()) + || work > 1_000_000 + || curve.get("control").is_some_and(|control| control.as_bool() != Some(false)) + || curve.get("degree").is_some_and(|degree| degree.as_u64() != Some(3)) + { + return Err(invalid()); + } + let count = points.ok_or_else(invalid)?.len(); + if indices.iter().any(|index| index.as_u64().is_none_or(|index| index >= count as u64)) { + return Err(invalid()); + } + } + } + Ok(()) +} + +fn validate_freeform_offsets(value: &Value) -> Result<()> { + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; + for feature in features { + if feature.get("type").and_then(Value::as_str) != Some("sketch") { + continue; + } + let Some(records) = feature.get("sketch").and_then(|sketch| sketch.get("freeform_offsets")) else { continue }; + let invalid = || DocError::Invalid("damaged design: invalid free-form offset metadata".into()); + let records = records.as_array().ok_or_else(invalid)?; + if records.len() > 128 { + return Err(invalid()); + } + let periodic_outputs: std::collections::BTreeSet<_> = records + .iter() + .filter(|record| record.get("certificate").and_then(Value::as_u64) == Some(2)) + .filter_map(|record| record.get("output").and_then(Value::as_str)) + .collect(); + let mut periodic_curves = std::collections::BTreeMap::new(); + if !periodic_outputs.is_empty() + && let Some(curves) = feature.get("sketch").and_then(|sketch| sketch.get("curves")).and_then(Value::as_array) + { + if curves.len() > 1_000_000 { + return Err(invalid()); + } + for curve in curves { + if let Some(id) = curve.get("id").and_then(Value::as_str) + && periodic_outputs.contains(id) + && periodic_curves.insert(id, curve).is_some() + { + return Err(invalid()); + } + } + } + let mut outputs = std::collections::BTreeSet::new(); + for record in records { + let object = record.as_object().ok_or_else(invalid)?; + if object.len() != 9 + || object.keys().any(|key| { + !matches!( + key.as_str(), + "source" | "output" | "forward" | "distance" | "divisions" | "pieces" | "closed" | "tolerance" | "certificate" + ) + }) + { + return Err(invalid()); + } + let source = record.get("source").and_then(Value::as_str).ok_or_else(invalid)?; + let output = record.get("output").and_then(Value::as_str).ok_or_else(invalid)?; + if [source, output].iter().any(|id| id.is_empty() || id.len() > 512 || id.chars().any(char::is_control)) + || source == output + || !outputs.insert(output) + { + return Err(invalid()); + } + let divisions = record.get("divisions").and_then(Value::as_u64).ok_or_else(invalid)?; + let pieces = record.get("pieces").and_then(Value::as_u64).ok_or_else(invalid)?; + let distance = record.get("distance").and_then(Value::as_f64).ok_or_else(invalid)?; + let tolerance = record.get("tolerance").and_then(Value::as_f64).ok_or_else(invalid)?; + if !(1..=256).contains(&divisions) + || !divisions.is_power_of_two() + || !(1..=256).contains(&pieces) + || pieces * divisions >= solvecraft_sketch::MAX_SPLINE_POINTS as u64 + || !distance.is_finite() + || !(1e-9..=MAX_REAL).contains(&distance.abs()) + || !tolerance.is_finite() + || !(tolerance > 0.0 && tolerance <= 0.002) + || !matches!(record.get("certificate").and_then(Value::as_u64), Some(1 | 2)) + || record.get("forward").and_then(Value::as_bool).is_none() + || record.get("closed").and_then(Value::as_bool).is_none() + { + return Err(invalid()); + } + if record.get("certificate").and_then(Value::as_u64) == Some(2) { + let output_curve = periodic_curves.get(output); + if record.get("closed").and_then(Value::as_bool) != Some(true) + || output_curve.is_none_or(|curve| { + curve.get("type").and_then(Value::as_str) != Some("spline") || curve.get("periodic").and_then(Value::as_bool) != Some(true) + }) + { + return Err(invalid()); + } + } + } + let mut resolved = std::collections::BTreeSet::new(); + for record in records { + let source = record.get("source").and_then(Value::as_str).ok_or_else(invalid)?; + let output = record.get("output").and_then(Value::as_str).ok_or_else(invalid)?; + if outputs.contains(source) && !resolved.contains(source) { + return Err(invalid()); + } + resolved.insert(output); + } + } + Ok(()) +} + /// Largest coordinate or size a design may hold (mm): a kilometre-scale part is already far /// outside what the kernel's tolerances serve. const MAX_REAL: f64 = 1e9; @@ -210,6 +563,503 @@ fn normalise(d: &mut Document) -> Result<()> { mod tests { use super::*; + #[test] + fn version_nine_wire_spaces_preserve_saved_points_and_explicit_markers() { + use solvecraft_geom::Vec3; + use solvecraft_sketch::{LinkKind, LinkSource, Sketch, WireSpace}; + let mut sketch = Sketch::new(); + sketch.add_drawn_wire(&[Vec3::new(12.0, 3.0, 4.0)]).unwrap(); + let link = sketch + .add_link_with_wires( + LinkKind::Include, + LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO }, + &[], + &[vec![Vec3::new(2.0, 7.0, 9.0), Vec3::new(2.0, 8.0, 9.0)]], + ) + .unwrap(); + sketch.set_link_lost(&link, true); + let mut doc = Document::new("legacy wire spaces"); + doc.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap(); + json["format"] = Value::from("solvecraft/9"); + for wire in json["features"][0]["sketch"]["wires"].as_array_mut().unwrap() { + wire.as_object_mut().unwrap().remove("space"); + } + let old = json["features"][0]["sketch"]["wires"].clone(); + let migrated = read(&json.to_string()).unwrap(); + let crate::FeatureKind::Sketch { sketch, .. } = &migrated.features[0].kind else { panic!("sketch") }; + assert_eq!(sketch.wires[0].space, WireSpace::World); + assert_eq!(sketch.wires[1].space, WireSpace::Component); + let saved: Value = serde_json::from_str(&migrated.to_json()).unwrap(); + for (a, b) in old.as_array().unwrap().iter().zip(saved["features"][0]["sketch"]["wires"].as_array().unwrap()) { + assert_eq!(a["pts"], b["pts"]); + assert_eq!(a["fit"], b["fit"]); + assert_eq!(a["link"], b["link"]); + } + assert_eq!(read(&migrated.to_json()).unwrap(), migrated); + json["features"][0]["sketch"]["wires"][1]["space"] = Value::from("world"); + let explicit = read(&json.to_string()).unwrap(); + let crate::FeatureKind::Sketch { sketch, .. } = &explicit.features[0].kind else { panic!("sketch") }; + assert_eq!(sketch.wires[1].space, WireSpace::World); + } + + #[test] + fn hostile_wire_and_transfer_shapes_are_refused_before_typed_decode() { + let base: Value = serde_json::from_str(&Document::new("bounds").to_json()).unwrap(); + let wire = serde_json::json!({"id":"w1", "pts":[[1,2,3],[4,5,6]],"space":"world"}); + for damaged in [ + serde_json::json!({"space":"guess"}), + serde_json::json!({"pts":[[1,2]]}), + serde_json::json!({"pts":[[1e9,2,3],[4,5,6]]}), + serde_json::json!({"fit": vec![[1,2,3];501]}), + ] { + let mut changed = wire.clone(); + for (key, value) in damaged.as_object().unwrap() { + changed[key] = value.clone(); + } + let mut file = base.clone(); + file["features"] = serde_json::json!([{"type":"sketch","sketch":{"wires":[changed]}}]); + assert!(validate_wires(&file, FORMAT).is_err()); + } + let transfer = serde_json::json!({"type":"body_transfer","component":1,"bodies":["Body1"],"from":0,"to":1,"transform":crate::IDENTITY}); + for damaged in [ + serde_json::json!({"bodies":["Body1","Body1"]}), + serde_json::json!({"from":-1}), + serde_json::json!({"component":0}), + serde_json::json!({"transform":[[2,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}), + serde_json::json!({"bodies":["\n"]}), + ] { + let mut changed = transfer.clone(); + for (key, value) in damaged.as_object().unwrap() { + changed[key] = value.clone(); + } + let mut file = base.clone(); + file["features"] = serde_json::json!([changed]); + assert!(validate_body_transfers(&file).is_err()); + } + } + + #[test] + fn recorded_transfer_roundtrips_and_unknown_component_is_refused() { + let mut doc = Document::new("recorded ownership boundary"); + doc.add_feature(crate::FeatureKind::BodyTransfer { bodies: vec!["Body1".into()], from: 0, to: 0, transform: crate::IDENTITY }, None).unwrap(); + assert_eq!(read(&doc.to_json()).unwrap(), doc); + let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap(); + assert_eq!(json["features"][0]["type"], "body_transfer"); + json["features"][0]["to"] = Value::from(12345); + json["features"][0]["component"] = Value::from(12345); + assert!(read(&json.to_string()).unwrap_err().to_string().contains("unknown component")); + json["features"][0]["to"] = Value::from(0); + assert!(read(&json.to_string()).unwrap_err().to_string().contains("owner must match")); + json["features"][0]["component"] = Value::from(0); + json["features"][0]["transform"][0][0] = Value::from(2); + assert!(read(&json.to_string()).unwrap_err().to_string().contains("rigid placement")); + } + + #[test] + fn version_eight_to_object_offset_keeps_existing_model_parameter_names() { + use crate::{Direction, Extent, FeatureKind, Operation, PlaneRef, ProfileSel}; + let mut document = Document::new("legacy named extent"); + document.set_param("offset", "2", Some("mm"), None).unwrap(); + let sketch = document + .add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: solvecraft_sketch::Sketch::new() }, None) + .unwrap(); + document + .add_feature( + FeatureKind::Extrude { + sketch, + profiles: ProfileSel::All, + extent: Extent { + distance: "10".into(), + direction: Direction::Negative, + distance2: None, + start_offset: None, + through_all: false, + taper: Some("0 deg".into()), + to: Some(solvecraft_geom::Vec3::new(0.0, 0.0, 10.0)), + to_offset: Some("offset + 1 mm".into()), + from: Some(solvecraft_geom::Vec3::new(0.0, 0.0, 5.0)), + }, + operation: Operation::NewBody, + targets: vec![], + }, + None, + ) + .unwrap(); + document + .add_feature( + FeatureKind::Box { + corner: ["0".into(), "0".into(), "0".into()], + length: "d1".into(), + width: "4".into(), + height: "6".into(), + operation: Operation::NewBody, + }, + None, + ) + .unwrap(); + let mut legacy: Value = serde_json::from_str(&document.to_json()).unwrap(); + legacy["format"] = Value::from("solvecraft/8"); + // Version 8 exposed distance and taper, but had no model slot for To Offset. + legacy["features"][1]["param_names"] = serde_json::json!(["d1", "d2"]); + legacy["features"][2]["param_names"] = serde_json::json!(["d3", "d4", "d5"]); + let old_kind = legacy["features"][1].clone(); + let upgraded = read(&legacy.to_string()).unwrap(); + assert_eq!(&upgraded.features[1].param_names[..2], ["d1", "d2"]); + assert_eq!(upgraded.features[2].param_names, ["d3", "d4", "d5"]); + assert_eq!(upgraded.features[1].param_names.len(), 3); + assert_eq!(upgraded.features[1].param_names[2], "d6"); + let saved: Value = serde_json::from_str(&upgraded.to_json()).unwrap(); + for field in ["extent", "sketch", "operation", "profiles"] { + assert_eq!(saved["features"][1][field], old_kind[field]); + } + let (values, errors) = upgraded.param_values(); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(values.get("d1").unwrap().v, 10.0); + assert_eq!(values.get("d3").unwrap().v, 10.0); + assert_eq!(values.get("d6").unwrap().v, 3.0); + assert_eq!(read(&upgraded.to_json()).unwrap(), upgraded); + } + + #[test] + fn saved_offset_cannot_alias_and_overwrite_its_source_geometry() { + use solvecraft_geom::Vec2; + use solvecraft_sketch::{CurveKind, Sketch}; + let mut sketch = Sketch::new(); + let center = sketch.add_point(Vec2::ZERO, None).unwrap(); + let major = sketch.add_point(Vec2::new(10.0, 0.0), None).unwrap(); + let source = sketch.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap(); + let output = sketch.add_freeform_offset(source, true, -1.0).unwrap(); + assert!(sketch.validate_freeform_offsets().is_ok()); + if let CurveKind::Spline { pts, .. } = &mut sketch.curves[output].kind { + pts[0] = center; + } else { + panic!("offset must produce a fit spline"); + } + let mut document = Document::new("aliased offset geometry"); + document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let error = read(&document.to_json()).unwrap_err().to_string(); + assert!(error.contains("damaged design"), "{error}"); + } + + #[test] + fn structurally_valid_offset_records_must_reference_real_sketch_geometry() { + let mut document = Document::new("missing offset geometry"); + document + .add_feature( + crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch: solvecraft_sketch::Sketch::new() }, + None, + ) + .unwrap(); + let mut value: Value = serde_json::from_str(&document.to_json()).unwrap(); + value["features"][0]["sketch"]["freeform_offsets"] = serde_json::json!([{ + "source":"ellipse", "output":"offset", "forward":true, "distance":2.0, + "divisions":8, "pieces":4, "closed":true, "tolerance":0.002, "certificate":1 + }]); + assert!(validate_freeform_offsets(&value).is_ok()); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(error.contains("damaged design"), "{error}"); + } + + #[test] + fn malformed_face_anchors_are_refused_before_typed_decoding() { + let anchor = serde_json::json!({ + "target":{"body":"Base", "origin_feature":1, "name":"Base:+z", "point":[0,0,10], + "certificate":{"kind":"box", "axis":2, "high":true}}, + "component":0, "path":[], "author_path":[], "coordinates":[0,0], "x_axis":[1,0] + }); + let make = |anchor: Value| { + serde_json::json!({"features":[{"type":"sketch", "plane":{ + "type":"face", "anchor":anchor + }}]}) + }; + assert!(crate::face_anchor::validate_face_anchors_json(&make(anchor.clone())).is_ok()); + let mut nested = serde_json::json!({"features":[{"type":"sketch", "plane":{ + "type":"offset", "base":{"type":"at_angle", "base":{"type":"face", "anchor":anchor.clone()}} + }}]}); + assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_ok()); + nested["features"][0]["plane"]["base"]["base"]["anchor"]["coordinates"] = serde_json::json!([0, 0, 0]); + assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_err()); + let mut malformed = Vec::new(); + for (key, damaged) in [ + ("component", serde_json::json!(-1)), + ("path", serde_json::json!([0])), + ("author_path", serde_json::json!((1..=65).collect::>())), + ("coordinates", serde_json::json!([1_000_000_001_u64, 0])), + ("coordinates", serde_json::json!([0])), + ("x_axis", serde_json::json!([0, 0])), + ("unknown", serde_json::json!(true)), + ] { + let mut candidate = anchor.clone(); + candidate[key] = damaged; + malformed.push(candidate); + } + let mut missing_certificate = anchor.clone(); + missing_certificate["target"].as_object_mut().unwrap().remove("certificate"); + malformed.push(missing_certificate); + let mut invalid_role = anchor; + invalid_role["target"]["certificate"]["axis"] = Value::from(3); + malformed.push(invalid_role); + for candidate in malformed { + let value = make(candidate); + assert!(crate::face_anchor::validate_face_anchors_json(&value).is_err()); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(!error.contains("design file:"), "must refuse before typed decode: {error}"); + } + } + + #[test] + fn periodic_markers_are_bounded_before_typed_decoding() { + let curve = serde_json::json!({"id":"closed", "type":"spline", "pts":[0,1,2,3], "periodic":true}); + let make = |curve: Value| { + serde_json::json!({"features":[{"type":"sketch", "sketch":{ + "points":[{}, {}, {}, {}], "curves":[curve] + }}]}) + }; + assert!(validate_versioned_geometry(&make(curve.clone())).is_ok()); + for (key, malformed) in [ + ("periodic", serde_json::json!("true")), + ("control", serde_json::json!(true)), + ("degree", serde_json::json!(2)), + ("pts", serde_json::json!([0, 1, 2])), + ("pts", serde_json::json!([0, 1, 2, u64::MAX])), + ("pts", serde_json::json!(vec![0; solvecraft_sketch::MAX_SPLINE_POINTS + 1])), + ] { + let mut damaged = curve.clone(); + damaged[key] = malformed; + assert!(validate_versioned_geometry(&make(damaged)).is_err(), "{key}"); + } + let mut legacy = curve; + legacy.as_object_mut().unwrap().remove("periodic"); + legacy["control"] = Value::Bool(true); + assert!(validate_versioned_geometry(&make(legacy)).is_ok()); + for convention in [serde_json::json!(null), serde_json::json!(false), serde_json::json!("unknown")] { + assert!(validate_versioned_geometry(&serde_json::json!({"features":[{"type":"revolve", "convention":convention}]})).is_err()); + } + } + + #[test] + fn periodic_offset_certificate_requires_closed_periodic_output() { + let record = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true, + "distance":2.0, "divisions":8, "pieces":4, "closed":true, + "tolerance":0.002, "certificate":2}); + let mut value = serde_json::json!({"features":[{"type":"sketch", "sketch":{ + "freeform_offsets":[record], "curves":[{"id":"offset", "type":"spline", "periodic":true}] + }}]}); + assert!(validate_freeform_offsets(&value).is_ok()); + let duplicate = value["features"][0]["sketch"]["curves"][0].clone(); + value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().push(duplicate); + assert!(validate_freeform_offsets(&value).is_err(), "periodic output ID must be unique"); + value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().pop(); + value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(false); + assert!(validate_freeform_offsets(&value).is_err()); + value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(true); + value["features"][0]["sketch"]["freeform_offsets"][0]["closed"] = Value::Bool(false); + assert!(validate_freeform_offsets(&value).is_err()); + value["features"][0]["sketch"]["freeform_offsets"][0]["certificate"] = Value::from(1); + assert!(validate_freeform_offsets(&value).is_ok(), "legacy certificate semantics remain valid"); + } + + #[test] + fn periodic_file_validation_refuses_damaged_closed_geometry() { + use solvecraft_geom::Vec2; + use solvecraft_sketch::{CurveKind, Sketch}; + let mut sketch = Sketch::new(); + let mut pts = Vec::new(); + for point in [Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 10.0), Vec2::ZERO] { + pts.push(sketch.add_point(point, None).unwrap()); + } + let first = pts[0]; + let neighbor = pts[1]; + let closing = pts[3]; + sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap(); + let mut document = Document::new("periodic file validation"); + document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + assert_eq!(read(&document.to_json()).unwrap(), document); + let mut value: Value = serde_json::from_str(&document.to_json()).unwrap(); + value["features"][0]["sketch"]["points"][closing]["pos"] = serde_json::json!([1, 0]); + assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic")); + value["features"][0]["sketch"]["points"][closing]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone(); + value["features"][0]["sketch"]["points"][neighbor]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone(); + assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic")); + } + + #[test] + fn freeform_offset_metadata_is_bounded_before_typed_decoding() { + let valid = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true, + "distance":2.0, "divisions":8, "pieces":4, "closed":true, + "tolerance":0.002, "certificate":1}); + let mut malformed = Vec::new(); + for (key, value) in [ + ("divisions", serde_json::json!(u64::MAX)), + ("pieces", serde_json::json!(u64::MAX)), + ("divisions", serde_json::json!(3)), + ("distance", serde_json::json!(0)), + ("tolerance", serde_json::json!(0.003)), + ("certificate", serde_json::json!(2)), + ("output", serde_json::json!("x".repeat(513))), + ("source", serde_json::json!("offset")), + ("unknown", serde_json::json!(true)), + ] { + let mut record = valid.clone(); + record[key] = value; + malformed.push(serde_json::json!([record])); + } + malformed.push(serde_json::json!(vec![valid.clone(); 129])); + malformed.push(serde_json::json!([valid.clone(), valid.clone()])); + let mut cycle = valid.clone(); + cycle["source"] = serde_json::json!("offset"); + cycle["output"] = serde_json::json!("ellipse"); + malformed.push(serde_json::json!([valid, cycle])); + for records in malformed { + let mut value: Value = serde_json::from_str(&Document::new("hostile offset").to_json()).unwrap(); + value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(error.contains("free-form offset metadata"), "{error}"); + value["format"] = Value::from(format_string(FORMAT + 1)); + assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolveCraft")); + } + } + + #[test] + fn explicit_emboss_target_bounds_precede_typed_decoding() { + for target in [ + serde_json::json!({"body":"", "at":[0,0,0]}), + serde_json::json!({"body":"Body", "at":[0,0]}), + serde_json::json!({"body":"Body", "at":[0,"invalid",0]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "unknown":true}), + serde_json::json!({"body":"x".repeat(513), "at":[0,0,0]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "path":"bad"}), + serde_json::json!({"body":"Body", "at":[0,0,0], "path":[0]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "path":[1,1]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "path":(1..=65).collect::>()}), + serde_json::json!({"body":"Body", "at":[0,0,0], "author_path":[1,1]}), + serde_json::json!({"body":"Body", "at":[0,0,0], "author_path":(1..=65).collect::>()}), + ] { + let mut value: Value = serde_json::from_str(&Document::new("hostile target").to_json()).unwrap(); + value["features"] = serde_json::json!([{"type":"emboss", "face":target}]); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(error.contains("emboss face"), "{error}"); + value["format"] = Value::from(format_string(FORMAT + 1)); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(error.contains("newer SolveCraft"), "{error}"); + } + } + + #[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, format_string(FORMAT)); + 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_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())]; + 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(); + 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, format_string(FORMAT)); + 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"], format_string(FORMAT)); + 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/joints.rs b/crates/doc/src/joints.rs index c3bfa8a6..ead63f33 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -223,7 +223,7 @@ pub fn motion(kind: JointKind, q: &[f64]) -> Mat { /// Bodies of a component in the model (its own frame). fn component_bodies<'a>(doc: &Document, st: &'a ModelState, component: u64) -> Vec<&'a crate::ModelBody> { - st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component).collect() + st.bodies.iter().filter(|b| doc.model_body_component(b) == component).collect() } /// The component an occurrence places (0 for the root). diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f4b6cfc9..02e849a1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -7,13 +7,19 @@ pub mod appearance; mod assembly; +mod body_transfer; +mod body_transfer_sheet; +pub use body_transfer::{valid_transfer_matrix, validate_body_transfer}; pub mod canvas; mod clipboard; +pub use clipboard::{clone_expression, remap_topology}; pub mod config; pub mod construct; mod document; mod eval; pub mod expr; +pub mod face_anchor; +pub use face_anchor::{FaceAnchor, FaceAttachmentStatus}; pub mod format; pub mod joints; pub mod motion; @@ -28,8 +34,9 @@ pub mod sheet; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid}; 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, + EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, + parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, state_in_frame, state_in_frame_at, + through_all_distance, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; @@ -51,5 +58,10 @@ pub enum DocError { pub type Result = std::result::Result; +#[cfg(test)] +mod named_projection_tests; #[cfg(test)] mod tests; + +mod anchor_frame; +pub use anchor_frame::state_in_face_anchor_frame; diff --git a/crates/doc/src/named_projection_tests.rs b/crates/doc/src/named_projection_tests.rs new file mode 100644 index 00000000..201f30b8 --- /dev/null +++ b/crates/doc/src/named_projection_tests.rs @@ -0,0 +1,50 @@ +use crate::{Document, ModelBody, ModelState}; +use solvecraft_geom::{Plane, Vec3}; +use solvecraft_sketch::{LinkKind, LinkSource, Sketch}; +use std::collections::BTreeMap; +use std::sync::Arc; + +fn named_box(name: &str, width: f64, height: f64) -> ModelBody { + let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(width, width, height)).unwrap(); + let mut names = vec!["unrelated".to_string(); body.faces(0.01).unwrap().len()]; + let top = body.faces(0.01).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99)).unwrap().index; + names[top] = "shared:top".into(); + let mut result = ModelBody::new(name.into(), body, 1); + result.names = crate::naming::NameCell::known(Arc::new(names)); + result +} + +#[test] +fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() { + let doc = Document::default(); + let mut initial = ModelState::default(); + initial.bodies.extend([named_box("Source", 20., 10.), named_box("Control", 5., 10.)]); + let plane = Plane::XY.offset(20.); + let source = LinkSource::Face { body: "Source".into(), at: Vec3::new(1., 1., 10.) }; + let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &initial, &plane, LinkKind::Project, &source).unwrap(); + let mut sketch = Sketch::default(); + let link = sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap(); + sketch.links[0].face_name = Some("shared:top".into()); + sketch.links[0].source = LinkSource::Face { body: "Source".into(), at: Vec3::new(0., 0., 10.) }; + let mut current = ModelState::default(); + current.bodies.extend([named_box("Source", 20., 20.), named_box("Control", 5., 10.)]); + assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), ¤t, &plane, &mut sketch).is_empty()); + assert!(!sketch.links[0].lost); + assert!(sketch.points.iter().any(|point| point.pos.x > 19.)); + assert!(sketch.points.iter().any(|point| point.pos.y > 19.)); + assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,at} if body=="Source" && (at.z-20.).abs()<1e-6)); + // The closest named point can sit on the wall/top seam: retain its actual + // face index rather than re-picking the adjacent vertical wall. + let index = crate::naming::face_on_body(current.body("Source").unwrap(), "shared:top", Vec3::new(0., 0., 10.)).unwrap().0; + assert!(current.body("Source").unwrap().body.faces(0.01).unwrap()[index].plane_normal.unwrap().z > 0.99); + let saved = sketch.clone(); + let mut missing = ModelState::default(); + missing.bodies.push(named_box("Control", 5., 10.)); + let warnings = crate::project::refresh_links(&doc, &BTreeMap::new(), &missing, &plane, &mut sketch); + assert!(sketch.links[0].lost); + assert!(!warnings.is_empty()); + assert_eq!(sketch.curves, saved.curves); + assert_eq!(sketch.points, saved.points); + assert_eq!(sketch.links[0].id, link); + assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,..} if body=="Source")); +} diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index a60c1478..133958bf 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -195,11 +195,22 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec { || xb.max.z < bb.min.z - tol || xb.min.z > bb.max.z + tol) }; + // Independently created solids own their faces even when another body + // occupies the same coordinates. Boolean operations still inherit inputs. + let independent_solid = matches!( + o.feature.kind, + FeatureKind::Box { operation: crate::Operation::NewBody, .. } + | FeatureKind::Cylinder { operation: crate::Operation::NewBody, .. } + | FeatureKind::Sphere { operation: crate::Operation::NewBody, .. } + | FeatureKind::Torus { operation: crate::Operation::NewBody, .. } + | FeatureKind::Extrude { operation: crate::Operation::NewBody, .. } + | FeatureKind::Revolve { operation: crate::Operation::NewBody, .. } + ); let inputs: Vec<(&ModelBody, FaceIndex, Arc>)> = o .before .bodies .iter() - .filter(|x| !x.body.is_mesh() && overlaps(x)) + .filter(|x| !independent_solid && !x.body.is_mesh() && overlaps(x)) .take(64) .map(|x| { let n = face_count(x); @@ -644,6 +655,21 @@ pub fn face_names_at(st: &ModelState, pts: &[Vec3]) -> Vec { .collect() } +/// Capture a face name from the recorded projection body only. +pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option { + if body.body.is_mesh() || !point.is_finite() { + return None; + } + let mesh = body.mesh(); + let count = face_count(body); + if mesh.triangles.len() > 2_000_000 || count > 50_000 { + return None; + } + let tolerance = (body.body.size() * 1e-3).max(1e-3); + let index = FaceIndex::new(&mesh, count).face_at(point, tolerance)?; + face_names(body).get(index).filter(|name| !name.is_empty()).cloned() +} + /// The point on the face with this name nearest `p` (a split face: its nearest piece, with /// `true` for "it was split"); `None` when no face has the name. pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> { @@ -668,3 +694,31 @@ pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool } best.map(|(_, q, split)| (q, split)) } + +/// Resolve a legacy named face only on its recorded source body. Return the +/// selected face index as well as the closest point, which may lie on a shared +/// edge and therefore must not be used to pick the face a second time. +pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3, bool)> { + if body.body.is_mesh() { + return None; + } + let names = face_names(body); + let mesh = body.mesh(); + let stem = strip_piece(name); + let mut best: Option<(f64, usize, Vec3, bool)> = None; + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + let index = usize::try_from(*face).ok()?; + let Some(candidate) = names.get(index) else { continue }; + let exact = candidate == name; + if !exact && strip_piece(candidate) != stem { + continue; + } + let Some([a, b, c]) = mesh.tri(triangle) else { continue }; + let q = closest(p, a, b, c); + let distance = q.dist(p) - if exact { 1e-9 } else { 0. }; + if best.is_none_or(|(old, _, _, _)| distance < old) { + best = Some((distance, index, q, !exact)); + } + } + best.map(|(_, index, q, split)| (index, q, split)) +} diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 73e7c69a..c1b54110 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -65,6 +65,9 @@ impl FeatureKind { if let Some(d) = &mut extent.taper { v.push(("Taper angle", d, A)); } + if let Some(d) = &mut extent.to_offset { + v.push(("To offset", d, L)); + } } FeatureKind::Revolve { angle, angle2, .. } => { v.push(("Angle", angle, A)); @@ -271,7 +274,8 @@ impl FeatureKind { | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::Import { .. } - | FeatureKind::MeshImport { .. } => {} + | FeatureKind::MeshImport { .. } + | FeatureKind::BodyTransfer { .. } => {} } v } @@ -502,6 +506,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 +690,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 +700,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 +773,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,9 +903,14 @@ 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 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..9d994b74 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 { @@ -29,6 +32,9 @@ pub struct Resolved { pub source: LinkSource, /// The reference was not exactly where it was and was re-found on the nearest geometry. pub moved: bool, + /// Exact face selected during this resolution, before its closest point is + /// projected onto an edge shared with another face. Computed metadata only. + pub face_index: Option, } /// Resolutions of body-based references, keyed by the reference, the sketch plane and the @@ -91,6 +97,7 @@ fn resolve_uncached( ) -> Result { let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists")); let mut moved = false; + let mut face_index = None; let mut source = src.clone(); let mut wires: Vec> = Vec::new(); // Included curves that leave the sketch plane stay 3D. @@ -122,6 +129,7 @@ fn resolve_uncached( LinkSource::Face { body, at } => { let (bi, mesh) = body_mesh(st, body, *at)?; let (f, q, d) = nearest_face(&mesh, *at).ok_or_else(|| lost("the face"))?; + face_index = Some(usize::try_from(f).map_err(|_| DocError::Invalid("face index exceeds platform limit".into()))?); let size = mesh.bounds().diagonal().max(1e-9); if d > size * 0.25 { return Err(lost("the face")); @@ -133,7 +141,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; } @@ -264,19 +272,130 @@ fn resolve_uncached( if geom.is_empty() && wires.is_empty() { return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe()))); } - Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved }) + Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index }) } /// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt /// links; lost links keep their last geometry. +fn lost(reference: &str) -> DocError { + DocError::Unknown(format!("{reference} no longer exists")) +} + +fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result { + let target = st.body(body).ok_or_else(|| lost("named projection body"))?; + let mesh = target.mesh(); + if mesh.triangles.len() > 2_000_000 || mesh.edges.len() > 2_000_000 { + return Err(DocError::Invalid("named face projection work limit exceeded".into())); + } + let face_index = Some(index); + let index = u32::try_from(index).map_err(|_| DocError::Invalid("named projection face index exceeds limit".into()))?; + let geom = if kind == LinkKind::Intersect { + section(plane, &mesh, Some(index)) + } else { + let mut geom = Vec::new(); + for edge in order::face_edges(&mesh, index, plane)? { + if mesh.seams.get(edge).copied().unwrap_or(false) { + continue; + } + if let Some(points) = mesh.edges.get(edge) { + geom.extend(fit_world(plane, points)); + } + } + merge_split_circles(geom) + }; + if geom.is_empty() { + return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into())); + } + Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index }) +} + +pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result { + let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?; + let indices = doc.configuration_face_indices(body, &anchor.target)?; + resolve_face_indices(st, plane, kind, &body.name, &indices) +} + +/// Project explicitly selected current face indices, without claiming persistent provenance. +/// Callers retaining a link must supply their own replay identity policy. +pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body_name: &str, indices: &[usize]) -> Result { + let body = st.body(body_name).ok_or_else(|| lost("projection body"))?; + let mesh = body.mesh(); + if mesh.triangles.len() > 2_000_000 || indices.len() > 10_000 { + return Err(DocError::Invalid("anchored projection work limit exceeded".into())); + } + let work = mesh.triangles.len().saturating_add(mesh.edges.len()).saturating_mul(indices.len()); + if work > 10_000_000 { + return Err(DocError::Invalid("anchored projection aggregate work limit exceeded".into())); + } + let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect(); + let mut geometry = Vec::new(); + for index in indices { + let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?; + if kind == LinkKind::Intersect { + geometry.extend(section(plane, &mesh, Some(index))); + } else { + for edge in order::face_edges(&mesh, index, plane)? { + if mesh.seams.get(edge).copied().unwrap_or(false) { + continue; + } + if let Some(points) = mesh.edges.get(edge) { + geometry.extend(fit_world(plane, points)); + } + } + } + } + let point = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .find_map(|(triangle, face)| selected.contains(&(*face as usize)).then(|| mesh.tri(triangle)).flatten().map(|[a, b, c]| (a + b + c) / 3.0)) + .ok_or_else(|| lost("anchored projection face"))?; + if geometry.is_empty() { + return Err(lost("anchored projection boundaries")); + } + Ok(Resolved { + geom: merge_split_circles(geometry), + wires: Vec::new(), + source: LinkSource::Face { body: body.name.clone(), at: point }, + moved: false, + face_index: if indices.len() == 1 { indices.first().copied() } else { None }, + }) +} + +pub fn refresh_face_anchor_links( + doc: &Document, + vals: &BTreeMap, + st: &ModelState, + plane: &Plane, + sk: &mut Sketch, + anchor: &crate::FaceAnchor, +) -> Vec { + refresh_links_in(doc, vals, st, plane, sk, Some(anchor)) +} + pub fn refresh_links(doc: &Document, vals: &BTreeMap, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec { + refresh_links_in(doc, vals, st, plane, sk, None) +} + +fn refresh_links_in( + doc: &Document, + vals: &BTreeMap, + st: &ModelState, + plane: &Plane, + sk: &mut Sketch, + anchor: Option<&crate::FaceAnchor>, +) -> Vec { let mut warn = Vec::new(); let links = sk.links.clone(); for l in &links { let mut source = l.source.clone(); - if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) { - if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) { + let mut named_face = None; + if !l.auto_face + && let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source) + { + if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) { *at = p; + named_face = Some((body.clone(), index, p)); if split { warn.push(format!("projected face `{name}` was split; using the nearest piece")); } @@ -286,7 +405,19 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap, st: &ModelS continue; } } - match resolve(doc, vals, st, plane, l.kind, &source) { + let resolved = if l.auto_face { + anchor + .filter(|anchor| { + l.face_name.as_ref() == Some(&anchor.target.name) && matches!(&l.source,LinkSource::Face {body,..} if *body==anchor.target.body) + }) + .ok_or_else(|| lost("exact auto-projected attachment")) + .and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor)) + } else if let Some((body, index, at)) = named_face { + resolve_named_face(st, plane, l.kind, &body, index, at) + } else { + resolve(doc, vals, st, plane, l.kind, &source) + }; + match resolved { Ok(r) => { sk.set_link_source(&l.id, r.source); let _ = sk.set_link_wires(&l.id, &r.wires); @@ -439,9 +570,9 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec (Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }], _ => Vec::new(), }, - Some(CurveKind::Spline { pts, control, degree }) if parallel => { + Some(CurveKind::Spline { pts, control, degree, periodic }) if parallel => { let p: Option> = pts.iter().map(|q| sk.point(*q).map(map)).collect(); - p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree }]).unwrap_or_default() + p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic }]).unwrap_or_default() } Some(CurveKind::Conic { a, b, apex, rho }) if parallel => match (sk.point(*a), sk.point(*apex), sk.point(*b)) { (Some(a), Some(x), Some(b)) => vec![LinkGeom::Conic { a: map(a), apex: map(x), b: map(b), rho: *rho }], @@ -557,7 +688,7 @@ fn as_spline(pts: &[Vec2], tol: f64) -> Option { <= tol }); if ok { - return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3 }); + return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3, periodic: false }); } } None diff --git a/crates/doc/src/project3d.rs b/crates/doc/src/project3d.rs index 4a5b24ff..df1bb10b 100644 --- a/crates/doc/src/project3d.rs +++ b/crates/doc/src/project3d.rs @@ -456,7 +456,7 @@ fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec { LinkGeom::Ellipse { c, major, minor } => { (0..=96).map(|i| solvecraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect() } - LinkGeom::Spline { ref pts, control, degree } => solvecraft_sketch::spline_polyline(pts, control, degree), + LinkGeom::Spline { ref pts, control, degree, periodic } => solvecraft_sketch::spline_polyline_mode(pts, control, degree, periodic), LinkGeom::Point(_) => Vec::new(), } } 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/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 6bee00c8..eb06fe07 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -16,6 +16,126 @@ 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)> { + regions_complexity(regions.iter()) +} + +fn regions_complexity<'a>(regions: impl Iterator) -> Result<(usize, usize)> { + let (mut edges, mut points) = (0usize, 0usize); + let mut pieces = 0usize; + for region in regions { + pieces += 1; + if pieces > MAX_FLAT_PIECES || region.holes.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + for lp in std::iter::once(®ion.outer).chain(®ion.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)) +} + +pub(crate) 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. +pub(crate) 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 { @@ -178,6 +298,10 @@ pub struct SheetBody { pub flanges: Vec, /// Cut-outs (flat coordinates) through the sheet. pub holes: Vec, + /// Planar machining in flat coordinates, with depths measured from the flat bottom. + /// Derived from feature history; these records are not a document schema change. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pockets: Vec, /// Unfolded. pub flat: bool, /// Gap between flanges that meet at a corner. @@ -187,6 +311,13 @@ pub struct SheetBody { pub base_curves: Vec<(String, Vec2, Vec2)>, } +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct SheetPocket { + pub region: Region2, + pub z0: f64, + pub z1: f64, +} + /// A piece of the sheet: a panel (None = the base) or a flange's bend zone. #[derive(Clone, Copy, Debug, PartialEq)] enum Piece { @@ -424,65 +555,71 @@ 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); + } + cuts.extend(self.through_pocket_regions(work)?); + 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>, Vec>) = loops.into_iter().partition(|l| signed_area(l) > 0.0); - for o in outers { - let mut hs: Vec> = - holes.iter().filter(|h| h.first().is_some_and(|p| point_in(&o, *p))).map(|h| Loop2::polygon(h).segs).collect(); - hs.extend(inner.iter().filter(|h| h.first().is_some_and(|s| point_in(&o, s.start()))).cloned()); - out.push(Cell { piece, outer: Loop2::polygon(&o).segs, holes: hs }); + out.push(Cell { piece, outer: region.outer.segs, holes: region.holes.into_iter().map(|l| l.segs).collect() }); } } Ok(out) } - /// Faces of the solid made by `cells` between heights `z0` and `z1`. - fn faces(&self, cells: &[Cell], z0: f64, z1: f64) -> Result> { + fn faces(&self, cells: &[Cell], z0: f64, z1: f64, work: &mut FlatWork) -> Result> { // Split straight edges where other cells' corners touch them, so neighbours share edges. + let edges = cells + .iter() + .fold(0usize, |n, c| n.saturating_add(c.outer.len()).saturating_add(c.holes.iter().fold(0usize, |m, h| m.saturating_add(h.len())))); + if edges > MAX_FLAT_POINTS || cells.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + work.charge(edges.saturating_mul(edges).saturating_mul(4))?; let corners: Vec = cells.iter().flat_map(|c| c.outer.iter().chain(c.holes.iter().flatten()).map(Seg2::start)).collect(); - let split_loop = |segs: &[Seg2]| -> Vec { + let mut split_edges = 0usize; + let mut split_loop = |segs: &[Seg2]| -> Result> { let mut v = Vec::new(); for e in segs { let Seg2::Line { a, b } = *e else { + split_edges += 1; + if split_edges > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } v.push(*e); continue; }; @@ -499,18 +636,31 @@ impl SheetBody { ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); let mut prev = a; for t in ts { + split_edges += 1; + if split_edges > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } let q = a + d * t; v.push(Seg2::Line { a: prev, b: q }); prev = q; } + split_edges += 1; + if split_edges > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } v.push(Seg2::Line { a: prev, b }); } - v + Ok(v) }; - let cells: Vec = cells - .iter() - .map(|c| Cell { piece: c.piece, outer: split_loop(&c.outer), holes: c.holes.iter().map(|h| split_loop(h)).collect() }) - .collect(); + let mut split_cells = Vec::new(); + for c in cells { + let outer = split_loop(&c.outer)?; + let holes = c.holes.iter().map(|h| split_loop(h)).collect::>()?; + split_cells.push(Cell { piece: c.piece, outer, holes }); + } + // Reserve the complete adjacency scan before visiting any wall. + work.charge(split_edges.saturating_mul(split_edges).saturating_mul(2))?; + let cells = split_cells; let near = |p: Vec2, q: Vec2| p.dist(q) < 1e-6; // Shared with a neighbour: the same flat edge the other way, landing in the same place // (flat neighbours across a corner of two bends do not). @@ -602,40 +752,204 @@ impl SheetBody { if self.flat { return self.flat_solid(); } - Ok(kernel::sew(&self.faces(&self.cells()?, 0.0, self.t)?)?) + let mut work = FlatWork::new(); + let cells = self.cells_with_work(&mut work)?; + let body = kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?; + self.machine_pockets(body, &mut work) } /// The flat pattern as one extrusion (outline with its cut-outs). pub fn flat_solid(&self) -> Result { - let loops = self.outline()?; - let mut loops: Vec = loops.into_iter().map(|l| Loop2::polygon(&l)).collect(); - loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs())); - let mut it = loops.into_iter(); - let outer = it.next().ok_or_else(|| DocError::Invalid("empty flat pattern".into()))?.ccw(); - let mut holes: Vec = it.map(|l| l.ccw().reversed()).collect(); - let op = outer.polyline(1e-3); - for h in &self.holes { - let hp = h.outer.polyline(1e-3); - if hp.iter().all(|p| point_in(&op, *p)) { - holes.push(h.outer.ccw().reversed()); - } else { - return Err(DocError::Invalid("not supported yet: cut-outs over the flat pattern's edge".into())); - } - } + let regions = self.flat_regions()?; let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?; - kernel::extrude(&pl, &[Region2 { outer, holes }], 0.0, self.t)?.into_iter().next().ok_or_else(|| DocError::Invalid("flat pattern".into())) + let body = kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?; + self.machine_pockets(body, &mut FlatWork::new()) } - /// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out. - pub fn outline(&self) -> Result>> { - let mut polys: Vec> = vec![dedup(&self.base.outer.ccw().polyline(1e-3))]; - for f in &self.flanges { - polys.push(dedup(&self.zone(f).outer.ccw().polyline(1e-3))); - if f.leg > 1e-9 { - polys.push(dedup(&self.panel(f).outer.ccw().polyline(1e-3))); + /// Replay exact planar machining after forming the full-thickness material. + fn machine_pockets(&self, mut body: Body, work: &mut FlatWork) -> Result { + if self.pockets.len() > 1000 { + return Err(flat_budget_error()); + } + regions_complexity(self.pockets.iter().map(|p| &p.region))?; + let panels = if self.flat { + vec![(None, self.flat_regions()?)] + } else { + self.material_panels(None, work)?.into_iter().map(|(pi, r)| (pi, vec![r])).collect() + }; + for pocket in &self.pockets { + if !pocket.z0.is_finite() || !pocket.z1.is_finite() || pocket.z0 < 0.0 || pocket.z1 > self.t || pocket.z0 >= pocket.z1 { + return Err(DocError::Invalid("invalid sheet pocket depth".into())); + } + if !self.flat { + for flange in &self.flanges { + if flat_regions_overlap(&pocket.region, &self.zone(flange), work)? { + return Err(DocError::Invalid( + "a sheet pocket crosses a bend zone; planar pocket machining cannot yet preserve curved bend material".into(), + )); + } + } + } + for (pi, panel) in &panels { + let clipped = material_boolean(panel, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, work)?; + if clipped.is_empty() { + continue; + } + let w = if self.flat { self.frame_mat() } else { self.world(*pi) }; + let plane = plane_of(&w, Vec3::ZERO, Vec3::X, Vec3::Y)?; + // BRep replay is costlier than planar clipping; share the command budget. + work.charge(100_000)?; + let tool = kernel::extrude_planar_regions(&plane, &clipped, pocket.z0, pocket.z1)?; + body = kernel::boolean(&body, &tool, kernel::BoolOp::Cut)? + .ok_or_else(|| DocError::Invalid("sheet pocket removes all sheet material".into()))?; } } - let loops = merge_loops(&polys); + Ok(body) + } + + /// Actual curved material, used to certify unsupported folded machining before mutation. + pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result> { + if self.flat || self.flanges.is_empty() { + return Ok(None); + } + let mut work = FlatWork::new(); + let cells: Vec<_> = self + .cells_with_work(&mut work)? + .into_iter() + .filter(|c| match c.piece { + Piece::Zone(_) => true, + Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6, + }) + .collect(); + if cells.is_empty() { + return Ok(None); + } + Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?)) + } + + fn through_pocket_regions(&self, work: &mut FlatWork) -> Result> { + if self.pockets.is_empty() { + return Ok(Vec::new()); + } + if self.pockets.len() > 1000 { + return Err(flat_budget_error()); + } + regions_complexity(self.pockets.iter().map(|p| &p.region))?; + let mut levels = vec![0.0, self.t]; + for p in &self.pockets { + if !p.z0.is_finite() || !p.z1.is_finite() || p.z0 < 0.0 || p.z1 > self.t || p.z0 >= p.z1 { + return Err(DocError::Invalid("invalid sheet pocket depth".into())); + } + levels.extend([p.z0, p.z1]); + } + levels.sort_by(f64::total_cmp); + levels.dedup(); + work.charge(levels.len().saturating_mul(self.pockets.len()))?; + // Most pockets leave an untouched thickness layer. Certify that cheap case + // before arranging hundreds of unrelated profiles at every depth level. + if levels.windows(2).any(|zs| !self.pockets.iter().any(|p| p.z0 <= zs[0] && p.z1 >= zs[1])) { + return Ok(Vec::new()); + } + let mut through: Option> = None; + for zs in levels.windows(2) { + let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect(); + if active.is_empty() { + return Ok(Vec::new()); + } + let removed = material_union(active, work)?; + through = Some(match through { + None => removed, + Some(previous) => material_boolean(&previous, &removed, kernel::BoolOp::Intersect, work)?, + }); + if through.as_ref().is_some_and(Vec::is_empty) { + return Ok(Vec::new()); + } + } + Ok(through.unwrap_or_default()) + } + + /// Pocket operations clipped to the surviving flat material, with exact depth intervals. + pub fn machining(&self) -> Result> { + let material = self.flat_regions()?; + let mut work = FlatWork::new(); + let mut out = Vec::new(); + for pocket in &self.pockets { + for region in material_boolean(&material, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, &mut work)? { + if out.len() >= 1000 { + return Err(flat_budget_error()); + } + out.push(SheetPocket { region, z0: pocket.z0, z1: pocket.z1 }); + } + } + Ok(out) + } + + /// Actual flat material boundaries after cut-outs. The same panel and cut regions build + /// the folded solid; planar unions remove panel/bend seams and turn edge-crossing cuts + /// into notches. Lines and circular arcs remain exact through clipping and merging. + pub fn flat_regions(&self) -> Result> { + let regions = self.flat_material()?; + if regions.is_empty() { + return Err(DocError::Invalid("empty flat pattern".into())); + } + Ok(regions) + } + + pub(crate) fn flat_material(&self) -> Result> { + let mut work = FlatWork::new(); + // Merge structural pieces before cutting: clipping each panel first introduces + // coincident notch endpoints at seams, while a single material cut has no seams. + 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))); + if raw > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + let mut regions = Vec::new(); + let mut panel_points = 0usize; + for (_, r) in self.panels() { + let r = planar_region(&r)?; + panel_points = panel_points.saturating_add(region_complexity(std::slice::from_ref(&r))?.1); + if panel_points > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + regions.push(r); + } + for f in &self.flanges { + regions.push(planar_region(&self.zone(f))?); + region_complexity(®ions)?; + } + let regions = material_union(regions, &mut work)?; + 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); + } + cuts.extend(self.through_pocket_regions(&mut work)?); + let cuts = material_union(cuts, &mut work)?; + let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? }; + Ok(regions) + } + /// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out. + pub fn outline(&self) -> Result>> { + if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + let mut polys: Vec> = vec![dedup(&flat_polyline(&self.base.outer.ccw())?)]; + for f in &self.flanges { + polys.push(dedup(&flat_polyline(&self.zone(f).outer.ccw())?)); + if f.leg > 1e-9 { + polys.push(dedup(&flat_polyline(&self.panel(f).outer.ccw())?)); + } + flat_points(&polys)?; + } + let loops = merge_loops(&polys, &mut FlatWork::new())?; if loops.is_empty() { return Err(DocError::Invalid("empty flat pattern".into())); } @@ -670,13 +984,13 @@ impl SheetBody { /// the sewn (faces, edges, vertices). pub fn round_hole_correction(&self) -> (i64, i64, i64) { let (mut df, mut de, mut dv) = (0i64, 0i64, 0i64); - for h in &self.holes { + let Ok(regions) = self.flat_regions() else { return (0, 0, 0) }; + for h in regions.iter().flat_map(|r| &r.holes) { let arcs: Vec<(Vec2, f64)> = - h.outer.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect(); - let n = h.outer.segs.len() as i64; - let full = !arcs.is_empty() - && arcs.len() == h.outer.segs.len() - && arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9); + h.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect(); + let n = h.segs.len() as i64; + let full = + !arcs.is_empty() && arcs.len() == h.segs.len() && arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9); if full && n > 1 { df += 1 - n; de += 2 - 3 * n; @@ -688,7 +1002,7 @@ impl SheetBody { /// Bounding size of the flat pattern (x, y in the flat frame). pub fn flat_size(&self) -> Result<(f64, f64)> { - let loops = self.outline()?; + let loops: Vec> = self.flat_regions()?.iter().map(|r| r.outer.polyline(1e-3)).collect(); let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); for p in loops.iter().flatten() { lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); @@ -1114,8 +1428,9 @@ impl SheetBody { } /// Map a region in a world plane to flat coordinates through a panel's placement. - pub fn region_to_flat(&self, panel: Option, plane: &Plane, r: &Region2) -> Option { - let inv = mat_inverse(&self.world(panel))?; + pub fn region_to_flat(&self, panel: Option, plane: &Plane, r: &Region2) -> Result { + region_complexity(std::slice::from_ref(r))?; + let inv = mat_inverse(&self.world(panel)).ok_or_else(|| DocError::Invalid("invalid sheet panel frame".into()))?; let map = |p: Vec2| { let q = apply_point(&inv, plane.to_world(p)); Vec2::new(q.x, q.y) @@ -1138,16 +1453,18 @@ impl SheetBody { other => Seg2::Line { a: map(other.start()), b: map(other.end()) }, } }; - let curved_other = r.outer.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. })); - let mut lp = if curved_other { - Loop2::polygon(&r.outer.polyline(1e-3).into_iter().map(map).collect::>()) - } else { - Loop2 { segs: r.outer.segs.iter().map(seg).collect() } + let map_loop = |lp: &Loop2| -> Result { + let curved_other = lp.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. })); + if curved_other { + Ok(Loop2::polygon(&flat_polyline(lp)?.into_iter().map(map).collect::>())) + } else { + Ok(Loop2 { segs: lp.segs.iter().map(seg).collect() }) + } }; - if lp.signed_area() < 0.0 { - lp = lp.reversed(); - } - Some(Region2 { outer: lp, holes: Vec::new() }) + Ok(Region2 { + outer: map_loop(&r.outer)?.ccw(), + holes: r.holes.iter().map(|h| map_loop(h).map(|l| l.ccw().reversed())).collect::>()?, + }) } /// Panels whose plane is parallel to `plane` (world): (panel, region). @@ -1161,6 +1478,22 @@ impl SheetBody { }) .collect() } + + pub(crate) fn material_panels(&self, plane: Option<&Plane>, work: &mut FlatWork) -> Result, Region2)>> { + Ok(self + .cells_with_work(work)? + .into_iter() + .filter_map(|c| { + let Piece::Panel(pi) = c.piece else { + return None; + }; + if plane.is_some_and(|p| apply_vector(&self.world(pi), Vec3::Z).cross(p.normal()).len() > 1e-6) { + return None; + } + Some((pi, Region2 { outer: Loop2 { segs: c.outer }, holes: c.holes.into_iter().map(|segs| Loop2 { segs }).collect() })) + }) + .collect()) + } } fn point_in(poly: &[Vec2], p: Vec2) -> bool { @@ -1178,7 +1511,9 @@ fn point_in(poly: &[Vec2], p: Vec2) -> bool { /// The boundary of a union of counter-clockwise polygons that meet edge to edge: edges are /// split where corners touch them, edges running both ways cancel, the rest chain into loops /// (outer loops counter-clockwise, holes clockwise; straight runs merged). -fn merge_loops(polys: &[Vec]) -> Vec> { +fn merge_loops(polys: &[Vec], work: &mut FlatWork) -> Result>> { + let n = flat_points(polys)?; + work.charge(n.saturating_mul(n).saturating_mul(2))?; let mut segs: Vec<(Vec2, Vec2)> = Vec::new(); for p in polys { let m = p.len(); @@ -1208,9 +1543,13 @@ fn merge_loops(polys: &[Vec]) -> Vec> { ts.sort_by(|x, y| x.total_cmp(y)); ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); for w in ts.windows(2) { + if split.len() >= MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } split.push((*a + d * w[0], *a + d * w[1])); } } + work.charge(split.len().saturating_mul(split.len()).saturating_mul(2))?; let same = |p: Vec2, q: Vec2| p.dist(q) < 1e-6; let keep: Vec<(Vec2, Vec2)> = split .iter() @@ -1248,7 +1587,7 @@ fn merge_loops(polys: &[Vec]) -> Vec> { loops.push(clean); } } - loops + Ok(loops) } fn signed_area(p: &[Vec2]) -> f64 { @@ -1256,27 +1595,6 @@ fn signed_area(p: &[Vec2]) -> f64 { (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 } -/// A convex polygon minus a convex polygon (both counter-clockwise), as convex pieces. -fn convex_diff(a: &[Vec2], h: &[Vec2]) -> Vec> { - let mut out = Vec::new(); - let mut rest = a.to_vec(); - let m = h.len(); - for i in 0..m { - let (Some(&p), Some(&q)) = (h.get(i), h.get((i + 1) % m)) else { continue }; - let Some(d) = (q - p).normalized() else { continue }; - let left = Vec2::new(-d.y, d.x); - let outside = dedup(&clip(&rest, p, left * -1.0)); - if outside.len() >= 3 && signed_area(&outside).abs() > 1e-12 { - out.push(outside); - } - rest = dedup(&clip(&rest, p, left)); - if rest.len() < 3 { - break; - } - } - out -} - /// Do two polygons' edges cross? fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool { let seg = |p: &[Vec2], i: usize| -> Option<(Vec2, Vec2)> { Some((*p.get(i)?, *p.get((i + 1) % p.len())?)) }; @@ -1291,25 +1609,25 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool { }) } -fn is_convex(p: &[Vec2]) -> bool { - let m = p.len(); - (0..m).all(|i| match (p.get(i), p.get((i + 1) % m), p.get((i + 2) % m)) { - (Some(a), Some(b), Some(c)) => (*b - *a).cross(*c - *b) >= -1e-9, - _ => true, - }) +/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel. +pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result { + let (a, b) = (planar_region(a)?, planar_region(b)?); + Ok(!material_boolean(&[a], &[b], kernel::BoolOp::Intersect, work)?.is_empty()) } /// A sheet from a closed profile (base flange): the region in `plane`, thickness along its normal. pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region: Region2) -> SheetBody { + let holes = region.holes.into_iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect(); SheetBody { body: body.into(), rule: rule.into(), t: rv.thickness, k: rv.k_factor, frame: plane, - base: region, + base: Region2 { outer: region.outer, holes: Vec::new() }, flanges: Vec::new(), - holes: Vec::new(), + holes, + pockets: Vec::new(), flat: false, gap: rv.gap, base_curves: Vec::new(), @@ -1361,6 +1679,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt base: Region2 { outer: Loop2 { segs: Vec::new() }, holes: Vec::new() }, flanges: Vec::new(), holes: Vec::new(), + pockets: Vec::new(), flat: false, gap: rv.gap, base_curves: Vec::new(), @@ -1510,4 +1829,138 @@ mod tests { s.flat = true; assert!(s.solid().is_ok()); } + + #[test] + fn disconnected_material_keeps_each_hole_with_its_own_region() { + let mut s = plate(100.0, 60.0); + s.holes.push(rect(Vec2::new(45.0, -5.0), Vec2::new(55.0, -5.0), Vec2::new(55.0, 65.0), Vec2::new(45.0, 65.0))); + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] }); + let regions = s.flat_regions().unwrap(); + assert_eq!(regions.len(), 2); + let left = regions.iter().find(|r| r.outer.contains(Vec2::new(10.0, 10.0))).unwrap(); + let right = regions.iter().find(|r| r.outer.contains(Vec2::new(90.0, 10.0))).unwrap(); + assert_eq!(left.holes.len(), 1); + assert!(left.holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); + assert!(right.holes.is_empty()); + let area: f64 = regions.iter().map(Region2::area).sum(); + assert!((area - (5400.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6); + assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1); + } + + #[test] + fn ring_cut_retains_exact_central_material_island() { + let mut s = plate(100.0, 60.0); + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 10.0), holes: vec![Loop2::circle(Vec2::new(50.0, 30.0), 5.0)] }); + let regions = s.flat_regions().unwrap(); + assert_eq!(regions.len(), 2); + let island = regions.iter().find(|r| r.outer.contains(Vec2::new(50.0, 30.0))).unwrap(); + assert!(island.holes.is_empty()); + assert!(island.outer.segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); + let area: f64 = regions.iter().map(Region2::area).sum(); + assert!((area - (6000.0 - std::f64::consts::PI * 75.0)).abs() < 1e-6); + assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1); + } + + #[test] + fn freeform_profile_uses_bounded_sampling_with_exact_round_holes() { + let mut s = plate(100.0, 60.0); + s.base.outer.segs = vec![ + Seg2::Line { a: Vec2::ZERO, b: Vec2::new(100.0, 0.0) }, + Seg2::Line { a: Vec2::new(100.0, 0.0), b: Vec2::new(100.0, 60.0) }, + Seg2::Cubic { p0: Vec2::new(100.0, 60.0), p1: Vec2::new(200.0 / 3.0, 80.0), p2: Vec2::new(100.0 / 3.0, 80.0), p3: Vec2::new(0.0, 60.0) }, + Seg2::Line { a: Vec2::new(0.0, 60.0), b: Vec2::ZERO }, + ]; + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 3.0), holes: vec![] }); + let regions = s.flat_regions().unwrap(); + assert!(regions[0].outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. }))); + assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))); + // The cubic has x=100(1-t), y=60+60t(1-t), hence an exact blank area of 7000. + let expected = (7000.0 - std::f64::consts::PI * 9.0) * 2.5; + assert!((vol(&s.solid().unwrap()) - expected).abs() < 0.5); + assert!((vol(&s.flat_solid().unwrap()) - expected).abs() < 0.5); + } + + #[test] + fn flat_geometry_budgets_reject_aggregate_and_intermediate_work() { + let mut s = plate(100.0, 60.0); + // Each arc is individually legal, but their combined sampled points exceed the cap. + s.base.outer.segs = vec![Seg2::Arc { center: Vec2::ZERO, radius: 1000.0, start: 0.0, sweep: std::f64::consts::TAU }; 8]; + assert!(s.flat_regions().unwrap_err().to_string().contains("budget")); + let mut s = plate(100.0, 60.0); + s.holes = vec![Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 1.0), holes: vec![] }; MAX_FLAT_PIECES + 1]; + assert!(s.flat_regions().unwrap_err().to_string().contains("budget")); + let p = vec![vec![Vec2::ZERO; MAX_FLAT_POINTS]]; + assert!(merge_loops(&p, &mut FlatWork::new()).unwrap_err().to_string().contains("budget")); + // Exhausting a clipping/merge allowance must report an error before work begins. + assert!(merge_loops(&[vec![Vec2::ZERO, Vec2::X, Vec2::Y]], &mut FlatWork(0)).is_err()); + } + + #[test] + fn overlapping_round_cutouts_union_with_exact_arc_boundaries() { + let mut s = plate(100.0, 60.0); + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] }); + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(22.0, 30.0), 5.0), holes: vec![] }); + let regions = s.flat_regions().unwrap(); + assert_eq!(regions[0].holes.len(), 1); + assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); + let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt(); + let area = 6000.0 - (50.0 * std::f64::consts::PI - intersection); + assert!((regions[0].area() - area).abs() < 1e-6); + assert!((vol(&s.solid().unwrap()) - area * 2.5).abs() < 0.1); + } + + #[test] + fn nested_and_repeated_round_cutouts_keep_only_the_largest_exact_boundary() { + for reverse in [false, true] { + let mut s = plate(100.0, 60.0); + let mut radii = vec![3.0, 5.0, 5.0]; + if reverse { + radii.reverse(); + } + for radius in radii { + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), radius), holes: vec![] }); + } + let regions = s.flat_regions().unwrap(); + assert_eq!(regions[0].holes.len(), 1); + assert!((regions[0].area() - (6000.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6); + let volume = vol(&s.solid().unwrap()); + // Body measure tessellates curved faces; the exact region area above is the + // stronger analytic check, while this follows the existing sheet measure budget. + assert!((volume - (6000.0 - std::f64::consts::PI * 25.0) * 2.5).abs() < 0.1, "{volume}"); + assert_eq!(s.round_hole_correction(), (-1, -4, -2)); + } + } + + #[test] + fn sewing_budget_rejects_edge_splitting_growth_before_allocating_faces() { + let s = plate(100.0, 60.0); + let cells: Vec = (0..250) + .map(|i| Cell { + piece: Piece::Panel(None), + outer: Loop2::polygon(&[Vec2::new(i as f64, 0.0), Vec2::new(250.0, 0.0), Vec2::new(i as f64, 1.0)]).segs, + holes: vec![], + }) + .collect(); + assert!(s.faces(&cells, 0.0, 2.5, &mut FlatWork::new()).unwrap_err().to_string().contains("budget")); + assert!(s.faces(&cells[..1], 0.0, 2.5, &mut FlatWork(0)).unwrap_err().to_string().contains("budget")); + } + + #[test] + fn pocket_geometry_and_depth_budgets_are_checked_before_replay() { + let pocket = SheetPocket { + region: rect(Vec2::new(20.0, 20.0), Vec2::new(30.0, 20.0), Vec2::new(30.0, 30.0), Vec2::new(20.0, 30.0)), + z0: 0.0, + z1: 1.0, + }; + let mut s = plate(100.0, 60.0); + s.pockets = vec![pocket.clone(); 1001]; + assert!(s.flat_solid().unwrap_err().to_string().contains("budget")); + s.pockets = vec![pocket.clone()]; + s.pockets[0].region.outer.segs = vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::X }; MAX_FLAT_POINTS + 1]; + assert!(s.flat_solid().unwrap_err().to_string().contains("budget")); + for (z0, z1) in [(f64::NAN, 1.0), (-1.0, 1.0), (0.0, 3.0), (1.0, 1.0)] { + s.pockets = vec![SheetPocket { z0, z1, ..pocket.clone() }]; + assert!(s.flat_solid().unwrap_err().to_string().contains("depth")); + } + } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 6e318aec..3ea65a68 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -157,6 +157,7 @@ fn errors_are_reported_not_fatal() { targets: vec![], angle2: None, symmetric: false, + convention: Default::default(), to: None, }, None, diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 1740ac04..acb1f4a9 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"), CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch) .icon("plane") - .params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"), + .params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"), CommandSpec::new("browser.group", "New Group", group) .icon("folder") .params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"), @@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"), CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params( "visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \ - or folder?: bodies|sketches|construction|origin (all of it). View state: not saved, not an undo step", + or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). View state: not saved, not an undo step", ), ]; @@ -191,6 +191,66 @@ fn shown_item(s: &Session, key: &str) -> Option { fn visibility(s: &mut Session, p: &Value) -> Result { let cmd = "browser.visibility"; let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?; + if let Some(value) = p.get("paths") { + if p.get("items").is_some() || p.get("folder").is_some() { + return Err(bad(cmd, "give paths, items or folder, not several")); + } + let paths = + value.as_array().filter(|a| !a.is_empty() && a.len() <= 10_000).ok_or_else(|| bad(cmd, "paths must list 1..10000 occurrence paths"))?; + let paths: Vec> = paths + .iter() + .map(|value| { + 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, "each path must list 1..64 occurrence ids"))?; + s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?; + Ok(path) + }) + .collect::>()?; + if bool_(p, "isolate").unwrap_or(false) { + if !visible { + return Err(bad(cmd, "isolate requires visible:true")); + } + let mut hidden = Vec::new(); + let mut ancestor_bodies = Vec::new(); + let mut stack = vec![Vec::::new()]; + let mut count = 0; + while let Some(parent) = stack.pop() { + for child in s.occurrence_children(&parent)? { + count += 1; + if count > 100_000 { + return Err(bad(cmd, "isolate exceeds 100000 placements")); + } + if paths.iter().any(|selected| child.path.starts_with(selected)) { + continue; + } + if paths.iter().any(|selected| selected.starts_with(&child.path)) { + ancestor_bodies.extend(child.bodies.into_iter().map(|b| b.world)); + stack.push(child.path); + } else { + hidden.push(child.path); + } + } + } + let mut root_bodies: Vec = + s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0).map(|b| b.name.clone()).collect(); + root_bodies.extend(ancestor_bodies); + s.visibility.hidden_occurrences = hidden; + s.visibility.hidden_bodies = root_bodies; + s.revision += 1; + return Ok(json!({"visible":true,"isolated":paths,"visibility":s.visibility})); + } + for path in &paths { + s.visibility.hidden_occurrences.retain(|p| p != path); + if !visible { + s.visibility.hidden_occurrences.push(path.clone()); + } + } + s.revision += 1; + return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility})); + } let items: Vec = match (str_(p, "folder"), p.get("items")) { (Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")), (Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(), @@ -211,6 +271,9 @@ fn visibility(s: &mut Session, p: &Value) -> Result { (None, None) => return Err(bad(cmd, "`items` or `folder` is required")), }; let v = &mut s.visibility; + if visible && str_(p, "folder") == Some("bodies") { + v.hidden_occurrences.clear(); + } for it in &items { match it { Shown::Sketch(id) => { @@ -247,6 +310,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "units": u, "kept": [] })); } // What the geometry reads: every feature's length inputs. + let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten(); let before = s.doc.length_inputs(); s.doc_mut().units = u.to_string(); // Unit-less user parameters holding a bare number read it in the design's units where they @@ -278,6 +342,10 @@ fn document_units(s: &mut Session, p: &Value) -> Result { } } } + s.doc_mut().preserve_configuration_units(&old, &kept); + if let Some(row) = matched_configuration { + s.doc_mut().apply_configuration(&row)?; + } Ok(json!({ "units": u, "kept": kept })) } @@ -393,6 +461,19 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { // Later features refer to bodies by name. let mut failed = None; for f in d.features.iter_mut().filter(|f| f.id != fid) { + if let FeatureKind::Sketch { plane, sketch } = &mut f.kind + && let Some(anchor) = super::sketch::face::anchor_mut(plane) + && anchor.target.body == old + { + let ids = std::collections::BTreeMap::from([(anchor.target.origin_feature, anchor.target.origin_feature)]); + let bodies = std::collections::BTreeMap::from([(old.clone(), name.to_string())]); + anchor.remap_target(&ids, &bodies)?; + for link in &mut sketch.links { + if link.auto_face && matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==old) { + link.face_name = Some(anchor.target.name.clone()); + } + } + } let Ok(mut v) = serde_json::to_value(&f.kind) else { continue }; if replace_str(&mut v, &old, name, 0) { match serde_json::from_value(v) { @@ -413,6 +494,17 @@ 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(); + } + solvecraft_doc::config::Column::FaceAppearance(target) if target.body == old => { + target.body = name.to_string(); + } + _ => {} + } + } if let Some(o) = d.body_offsets.remove(&old) { d.body_offsets.insert(name.to_string(), o); } @@ -440,13 +532,89 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result { if p.get("plane").is_none() { return Err(bad(cmd, "`plane` is required")); } - let plane = super::sketch::plane_ref(s, p, cmd)?; - let (vals, _) = s.doc.param_values(); - s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?; - if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) { + let feature = s.doc.feature(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?; + let component = feature.component; + let index = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?; + let author_path = super::sketch::author_path_for_rebind(s, feature, p, cmd)?; + let marker = s.doc.marker; + let mut baseline = s.scratch(); + baseline.doc_mut().marker = Some(index + 1); + baseline.refresh(); + let visible_geometry = baseline.model.state().sketch(id).map(|resolved| resolved.sketch.clone()); + let mut trial = s.scratch(); + trial.active_component = component; + trial.active_occurrence_path = (!author_path.is_empty()).then_some(author_path); + // A new attachment may only depend on geometry preceding this sketch. + trial.doc_mut().marker = Some(index); + trial.refresh(); + let old_anchor = trial + .doc + .feature(id) + .and_then(|feature| match &feature.kind { + FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), + _ => None, + }) + .cloned(); + let plane = super::sketch::plane_ref(&trial, p, cmd)?; + if super::sketch::face::is_face(p) { + super::sketch::face::capture_positions(&mut trial, &plane)?; + } + if let Some(anchor) = old_anchor + && super::sketch::face::anchor(&plane) + .is_none_or(|next| next.target != anchor.target || next.component != anchor.component || next.path != anchor.path) + { + let sketch = trial.doc_mut().sketch_mut(id)?; + if let Some(mut visible) = visible_geometry { + let constraints: std::collections::BTreeMap<_, _> = + sketch.constraints.iter().map(|constraint| (constraint.id.clone(), constraint.clone())).collect(); + for constraint in &mut visible.constraints { + if let Some(original) = constraints.get(&constraint.id) { + if std::mem::discriminant(&constraint.kind) != std::mem::discriminant(&original.kind) { + return Err(bad(cmd, "a dimension role changed during border refresh; repair it explicitly before rebinding")); + } + // Keep evaluated entity indices after linked topology rebuilds. + constraint.param = original.param.clone(); + constraint.driven = original.driven; + constraint.text = original.text; + if let Some(value) = original.kind.value() { + constraint.kind.set_value(value); + } + } + } + *sketch = visible; + } + let affected: Vec<_> = sketch + .links + .iter() + .filter(|link| { + link.auto_face + && link.face_name.as_ref() == Some(&anchor.target.name) + && matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==anchor.target.body) + }) + .map(|link| link.id.clone()) + .collect(); + for link in affected { + sketch.break_link(&link)?; + } + } + if let Some(FeatureKind::Sketch { plane: slot, .. }) = trial.doc_mut().feature_mut(id).map(|feature| &mut feature.kind) { *slot = plane; } - Ok(json!({ "sketch": id })) + trial.doc_mut().marker = Some(index + 1); + trial.refresh(); + if let Some(error) = trial.model.result(id).and_then(|result| result.error.as_ref()) { + return Err(bad(cmd, error.clone())); + } + trial.doc_mut().marker = marker; + trial.refresh(); + s.doc = trial.doc; + s.pending_moves = trial.pending_moves; + + let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind { + FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }), + _ => None, + }); + Ok(json!({ "sketch": id,"attachment":attachment })) } #[cfg(test)] diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 026bf806..c26baefd 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -62,11 +62,68 @@ fn paste(s: &mut Session, p: &Value) -> Result { let feats = s.clipboard.clone(); let comp = s.active_component; let mut sketches: BTreeMap = BTreeMap::new(); + let mut bodies: BTreeMap = BTreeMap::new(); + let mut used: std::collections::BTreeSet = s.model.state().bodies.iter().map(|body| body.name.clone()).collect(); + let mut notices = Vec::new(); + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(comp)); + solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), comp, &author_path)?; let mut names: BTreeMap = BTreeMap::new(); let mut made = Vec::new(); for f in feats { let mut kind = f.kind.clone(); kind.remap_refs(&sketches, &names); + if let FeatureKind::Sketch { plane, .. } = &mut kind + && let Some(anchor) = super::sketch::face::anchor_mut(plane) + { + let internal = bodies.contains_key(&anchor.target.body); + if !internal && translate.is_some_and(|translate| translate.len() > 1e-12) { + return Err(bad(cmd, "translated paste retains an external exact face attachment; explicitly convert it to a fixed plane first")); + } + if !internal { + notices.push(format!("pasted sketch retains external exact face attachment to {}", anchor.target.body)); + } + anchor.remap_target(&sketches, &bodies)?; + if internal { + anchor.component = comp; + anchor.path = author_path.clone(); + } + anchor.author_path = author_path.clone(); + let target = anchor.target.clone(); + if let FeatureKind::Sketch { sketch, .. } = &mut kind { + for link in &mut sketch.links { + if link.auto_face { + if let solvecraft_sketch::LinkSource::Face { body, .. } = &mut link.source + && let Some(mapped) = bodies.get(body) + { + *body = mapped.clone(); + } + link.face_name = Some(target.name.clone()); + } + } + } + } + let outputs = if f.body_names.is_empty() { + s.model + .result(f.id) + .map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect::>()) + .unwrap_or_default() + } else { + f.body_names.clone() + }; + let copied_names: Vec<_> = outputs + .iter() + .map(|original| { + let base = format!("{original} (copy)"); + let mut name = base.clone(); + let mut suffix = 2usize; + while !used.insert(name.clone()) { + name = format!("{base} {suffix}"); + suffix += 1; + } + bodies.insert(original.clone(), name.clone()); + name + }) + .collect(); let doc = s.doc_mut(); if let FeatureKind::Sketch { sketch, .. } = &mut kind { // Each dimension of the copy gets its own parameter, with the same expression. @@ -90,12 +147,10 @@ fn paste(s: &mut Session, p: &Value) -> Result { let id = doc.add_feature(kind, None)?; if let Some(nf) = doc.feature_mut(id) { nf.component = comp; - nf.body_names = f.body_names.iter().map(|b| format!("{b} (copy)")).collect(); + nf.body_names = copied_names; } let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default(); - if matches!(f.kind, FeatureKind::Sketch { .. }) { - sketches.insert(f.id, id); - } + sketches.insert(f.id, id); names.insert(f.name.clone(), new_name.clone()); made.push(json!({"id": id, "name": new_name, "from": f.name})); } @@ -105,7 +160,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { .filter_map(|m| m["id"].as_u64()) .filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e}))) .collect(); - Ok(json!({"pasted": made, "errors": errors})) + Ok(json!({"pasted": made, "errors": errors, "notices":notices})) } #[cfg(test)] diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 62b8cc3a..16bda80f 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -8,31 +8,47 @@ use solvecraft_doc::{FeatureKind, Mat, Occurrence}; use solvecraft_geom::Vec3; use super::CommandSpec; -use crate::params::{bad, bool_, num, str_, string_list, vec3}; +use crate::params::{bad, bool_, str_, string_list, vec3}; use crate::{Result, Session}; +#[path = "component_properties.rs"] +pub(super) mod properties; +#[path = "component_transfer.rs"] +mod transfer; + +#[cfg(test)] +#[path = "occurrence_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod occurrence_tests; + pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.create", "New Component", new_component) .at("SOLID", "ASSEMBLE") .icon("component") - .params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"), + .params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true)"), CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies) .icon("component") .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.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"), 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"), CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"), CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence) - .params("component (or occurrence): what to place again; parent?: component (default: the same parent); translate?: [x,y,z]"), - CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its features into a new, independent component; translate?: [x,y,z]"), + .params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"), + CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"), CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence) - .params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"), + .params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"), CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"), CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"), - CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"), + CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"), ]; fn key(v: Option<&Value>) -> Option { @@ -57,12 +73,61 @@ fn translation(t: Vec3) -> Mat { solvecraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0) } +fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result { + let v = match p.get(key) { + None => default, + Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?, + }; + if !v.is_finite() || (coordinate && [v.x, v.y, v.z].iter().any(|x| x.abs() > 1e9)) { + return Err(bad(cmd, format!("{key} exceeds supported numeric range"))); + } + Ok(v) +} + +pub(super) fn valid_position(m: &Mat) -> bool { + let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]); + let (x, y, z) = (column(0), column(1), column(2)); + m.iter().flatten().all(|x| x.is_finite()) + && m[3][..3].iter().all(|x| x.abs() <= 1e9) + && [x, y, z].iter().all(|axis| (axis.dot(*axis) - 1.0).abs() < 1e-6) + && x.dot(y).abs() < 1e-6 + && x.dot(z).abs() < 1e-6 + && y.dot(z).abs() < 1e-6 + && x.cross(y).dot(z) > 0.0 + && [m[0][3], m[1][3], m[2][3]].iter().all(|x| x.abs() < 1e-9) + && (m[3][3] - 1.0).abs() < 1e-9 +} + fn new_component(s: &mut Session, p: &Value) -> Result { let cmd = "component.create"; let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component }; + let parent_path = if let Some(value) = p.get("parent_path") { + let path = value + .as_array() + .filter(|a| a.len() < 64) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .ok_or_else(|| bad(cmd, "parent_path must list up to 63 occurrence ids"))?; + let component = if path.is_empty() { 0 } else { s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?.component }; + if component != parent { + return Err(bad(cmd, "parent_path belongs to another component")); + } + path + } else if parent == s.active_component { + s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(parent)) + } else { + s.doc.first_path(parent) + }; + if parent_path.len() >= 64 { + return Err(bad(cmd, "new component exceeds occurrence depth limit")); + } let id = s.doc_mut().add_component(str_(p, "name"), parent)?; if p.get("activate").and_then(Value::as_bool).unwrap_or(true) { + let mut path = parent_path; + if let Some(o) = s.doc.occurrence_of(id) { + path.push(o.id); + } s.active_component = id; + s.active_occurrence_path = Some(path); } let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default(); let occ = s.doc.occurrence_of(id).map(|o| o.id); @@ -70,30 +135,23 @@ fn new_component(s: &mut Session, p: &Value) -> Result { } fn from_bodies(s: &mut Session, p: &Value) -> Result { - let cmd = "component.from_bodies"; - let bodies = string_list(p, "bodies"); - if bodies.is_empty() || bodies.len() > 1000 { - return Err(bad(cmd, "`bodies` must list 1…1000 body names")); - } - let st = s.model.state(); - for b in &bodies { - if st.body(b).is_none() { - return Err(bad(cmd, format!("no body `{b}`"))); - } - } - let parent = s.active_component; - let mut made = Vec::new(); - for b in bodies { - let id = s.doc_mut().add_component(Some(&b), parent)?; - s.doc_mut().body_components.insert(b.clone(), id); - made.push(json!({"component": id, "body": b})); - } - Ok(json!({ "components": made })) + transfer::from_bodies(s, p) } fn activate(s: &mut Session, p: &Value) -> Result { let id = component(s, p, "component", "component.activate")?; + let path = if p.get("path").is_some() || p.get("occurrence").is_some() { + let occurrence = occurrence(s, p, "component.activate")?; + let path = occurrence_path(s, p, occurrence, "component.activate")?; + if s.occurrence_placement(&path)?.component != id { + return Err(bad("component.activate", "path belongs to another component")); + } + Some(path) + } else { + None + }; s.active_component = id; + s.active_occurrence_path = path; s.revision += 1; Ok(json!({ "active": id })) } @@ -102,7 +160,7 @@ fn list(s: &mut Session, _p: &Value) -> Result { let st = s.model.state(); let doc = &s.doc; let node = |id: u64, name: &str, parent: Option| { - let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect(); + let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect(); let features: Vec<&str> = doc .features .iter() @@ -149,12 +207,22 @@ fn delete(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the design itself can't be deleted")); } let gone: Vec = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect(); + if s.doc.features.iter().any(|feature| { + !gone.contains(&feature.component) + && matches!(&feature.kind, FeatureKind::BodyTransfer { from, to, .. } if gone.contains(from) || gone.contains(to)) + }) { + return Err(bad( + cmd, + "component supplies a recorded body transfer outside this subtree; remove that transfer history before deleting its source", + )); + } let feats: Vec = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect(); let d = s.doc_mut(); for f in &feats { let _ = d.delete_feature(*f); } d.components.retain(|c| !gone.contains(&c.id)); + d.remove_configuration_components(&gone); d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent)); let moved_out: Vec = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect(); d.body_components.retain(|_, c| !gone.contains(c)); @@ -166,36 +234,11 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn move_bodies(s: &mut Session, p: &Value) -> Result { - let cmd = "component.move_bodies"; - let to = component(s, p, "component", cmd)?; - let bodies = string_list(p, "bodies"); - let st = s.model.state(); - if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) { - return Err(bad(cmd, "`bodies` must list existing bodies")); - } - for b in &bodies { - let feature = st.body(b).map(|x| x.feature).unwrap_or(0); - let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0); - // The body keeps its place in the world: where it is shown now (its component's frame and - // any earlier offset), seen from the new component's frame. - let from = s.doc.body_component(b, feature); - let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY)); - let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to)) - .map(|inv| solvecraft_doc::mat_mul(&inv, &now)) - .unwrap_or(solvecraft_doc::IDENTITY); - let doc = s.doc_mut(); - if own == to { - doc.body_components.remove(b); - } else { - doc.body_components.insert(b.clone(), to); - } - if solvecraft_doc::is_identity(&offset) { - doc.body_offsets.remove(b); - } else { - doc.body_offsets.insert(b.clone(), offset); - } - } - Ok(json!({"bodies": bodies, "component": to})) + transfer::move_bodies(s, p) +} + +pub(crate) fn transfer_constructor_result(s: &mut Session, p: &Value) -> Result { + transfer::move_result(s, p) } fn move_sketches(s: &mut Session, p: &Value) -> Result { @@ -222,16 +265,30 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result { fn copy_occurrence(s: &mut Session, p: &Value) -> Result { let cmd = "occurrence.copy"; - let comp = if p.get("occurrence").is_some() { + let selected = if p.get("occurrence").is_some() { let o = occurrence(s, p, cmd)?; - s.doc.occurrences.iter().find(|x| x.id == o).map(|x| x.component).unwrap_or(0) + Some(o) + } else { + None + }; + let comp = if let Some(id) = selected { + s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.component).unwrap_or(0) } else { component(s, p, "component", cmd)? }; - let src = s.doc.occurrence_of(comp).cloned().ok_or_else(|| bad(cmd, "that component has no occurrence"))?; + let src = if let Some(id) = selected { s.doc.occurrences.iter().find(|o| o.id == id) } else { s.doc.occurrence_of(comp) } + .cloned() + .ok_or_else(|| bad(cmd, "that component has no occurrence"))?; + if p.get("path").is_some() { + occurrence_path(s, p, src.id, cmd)?; + } let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { src.parent }; - let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO); - let m = solvecraft_doc::mat_mul(&translation(t), &src.transform); + let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?; + let base = s.pending_moves.get(&src.id).unwrap_or(&src.transform); + let m = solvecraft_doc::mat_mul(&translation(t), base); + if !valid_position(&m) { + return Err(bad(cmd, "copy transform exceeds supported numeric range")); + } let id = s.doc_mut().add_occurrence(comp, parent, m)?; Ok(json!({"occurrence": id, "component": comp})) } @@ -243,68 +300,55 @@ fn paste_new(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "pick a component, not the design")); } let src = s.doc.components.iter().find(|c| c.id == comp).cloned().ok_or_else(|| bad(cmd, "component"))?; - let occ = s.doc.occurrence_of(comp).cloned(); - let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), src.parent)?; - // Copy the component's features with fresh ids and names; references between them follow. - let feats: Vec = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect(); - let mut idmap: std::collections::HashMap = Default::default(); - let mut namemap: std::collections::HashMap = Default::default(); - let mut new_ids = Vec::new(); - for f in &feats { - let nid = s.doc_mut().add_feature(f.kind.clone(), Some(&format!("{} (copy)", f.name)))?; - idmap.insert(f.id, nid); - let nname = s.doc.feature(nid).map(|x| x.name.clone()).unwrap_or_default(); - namemap.insert(f.name.clone(), nname); - new_ids.push(nid); - } - for nid in &new_ids { - let Some(f) = s.doc.feature(*nid).cloned() else { continue }; - let mut v = serde_json::to_value(&f.kind).map_err(|e| bad(cmd, e.to_string()))?; - remap(&mut v, &idmap, &namemap, 0); - let kind: FeatureKind = serde_json::from_value(v).map_err(|e| bad(cmd, e.to_string()))?; - if let Some(slot) = s.doc_mut().feature_mut(*nid) { - slot.kind = kind; - slot.component = new_id; + let occ = if p.get("occurrence").is_some() { + let id = occurrence(s, p, cmd)?; + if p.get("path").is_some() { + occurrence_path(s, p, id, cmd)?; } + let occurrence = s.doc.occurrences.iter().find(|o| o.id == id).cloned().ok_or_else(|| bad(cmd, "no occurrence"))?; + if occurrence.component != comp { + return Err(bad(cmd, "occurrence belongs to another component")); + } + Some(occurrence) + } else { + s.doc.occurrence_of(comp).cloned() + }; + let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?; + let base = occ.as_ref().map(|o| s.pending_moves.get(&o.id).copied().unwrap_or(o.transform)).unwrap_or(solvecraft_doc::IDENTITY); + let transform = solvecraft_doc::mat_mul(&translation(t), &base); + if !valid_position(&transform) { + return Err(bad(cmd, "copy transform exceeds supported numeric range")); } - let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO); - let base = occ.map(|o| o.transform).unwrap_or(solvecraft_doc::IDENTITY); - if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) { - o.transform = solvecraft_doc::mat_mul(&translation(t), &base); - } - Ok(json!({"component": new_id, "features": new_ids})) + let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent); + super::component_clone::paste(s, comp, occ, transform, parent, p) } -/// Point copied features at their copied sketches and features. -fn remap(v: &mut Value, ids: &std::collections::HashMap, names: &std::collections::HashMap, depth: usize) { - if depth > 32 { - return; - } - match v { - Value::Object(o) => { - for (k, x) in o.iter_mut() { - match (k.as_str(), &*x) { - ("sketch" | "path_sketch", Value::Number(n)) => { - if let Some(m) = n.as_u64().and_then(|i| ids.get(&i)) { - *x = json!(m); - } - } - ("features", Value::Array(_)) => { - if let Value::Array(a) = x { - for e in a.iter_mut() { - if let Some(m) = e.as_str().and_then(|n| names.get(n)) { - *e = json!(m); - } - } - } - } - _ => remap(x, ids, names, depth + 1), - } +pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result> { + let path = if let Some(value) = p.get("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"))? + } else { + let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?; + let mut path = s.doc.first_path(o.parent); + // A world frame requires an explicit containing placement when any ancestor is instanced. + for ancestor in &path { + if let Some(a) = s.doc.occurrences.iter().find(|a| a.id == *ancestor) + && s.doc.occurrences.iter().filter(|x| x.component == a.component).count() > 1 + { + return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")); } } - Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)), - _ => {} + path.push(id); + path + }; + if path.last() != Some(&id) { + return Err(bad(cmd, "path and occurrence disagree")); } + s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?; + Ok(path) } fn move_occurrence(s: &mut Session, p: &Value) -> Result { @@ -314,19 +358,56 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result { if o.grounded { return Err(bad(cmd, format!("{} is grounded", o.name))); } - let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO); - let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z); + let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?; + let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?; let angle = match p.get("angle") { Some(Value::String(e)) => s.doc.eval(e, solvecraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?, - _ => num(p, "angle").unwrap_or(0.0), + None => 0.0, + Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?, }; - if !(t.is_finite() && angle.is_finite()) { + if !(t.is_finite() && axis.is_finite() && angle.is_finite()) { return Err(bad(cmd, "non-finite move")); } - let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); + if angle != 0.0 && axis.normalized().is_none() { + return Err(bad(cmd, "rotation axis must have a finite nonzero length")); + } + let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?; let delta = solvecraft_doc::rigid(t, origin, axis, angle); - let cur = s.pending_moves.get(&id).copied().unwrap_or(o.transform); + let space = match p.get("space") { + None => "local", + Some(Value::String(space)) => space.as_str(), + _ => return Err(bad(cmd, "space must be local or world")), + }; + if !matches!(space, "local" | "world") { + return Err(bad(cmd, "space must be local or world")); + } + let path = if p.get("path").is_some() || space == "world" { Some(occurrence_path(s, p, id, cmd)?) } else { None }; + let delta = if space == "world" { + let placement = s.occurrence_placement(path.as_deref().unwrap_or(&[]))?; + let inverse = solvecraft_doc::mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?; + solvecraft_doc::mat_mul(&solvecraft_doc::mat_mul(&inverse, &delta), &placement.parent_world) + } else { + delta + }; + let cur = if let Some(value) = p.get("base_transform") { + let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?; + if !valid_position(&base) + || base[0][3].abs() > 1e-12 + || base[1][3].abs() > 1e-12 + || base[2][3].abs() > 1e-12 + || (base[3][3] - 1.0).abs() > 1e-12 + || solvecraft_doc::mat_inverse(&base).is_none() + { + return Err(bad(cmd, "base_transform must be finite, affine and invertible")); + } + base + } else { + s.pending_moves.get(&id).copied().unwrap_or(o.transform) + }; let next = solvecraft_doc::mat_mul(&delta, &cur); + if !valid_position(&next) { + return Err(bad(cmd, "move transform exceeds supported numeric range")); + } if bool_(p, "capture").unwrap_or(true) { s.pending_moves.remove(&id); if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) { @@ -342,6 +423,9 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result { fn ground(s: &mut Session, p: &Value) -> Result { let cmd = "occurrence.ground"; let id = occurrence(s, p, cmd)?; + if p.get("path").is_some() { + occurrence_path(s, p, id, cmd)?; + } let want = bool_(p, "grounded"); let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?; o.grounded = want.unwrap_or(!o.grounded); @@ -361,9 +445,24 @@ fn capture(s: &mut Session, _p: &Value) -> Result { Ok(json!({ "captured": n })) } -fn revert(s: &mut Session, _p: &Value) -> Result { - let n = s.pending_moves.len(); - s.pending_moves.clear(); +fn revert(s: &mut Session, p: &Value) -> Result { + let before = s.pending_moves.len(); + if let Some(value) = p.get("occurrences") { + let ids = value + .as_array() + .filter(|a| !a.is_empty() && a.len() <= 10_000) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .ok_or_else(|| bad("component.revert_position", "occurrences must list 1..10000 ids"))?; + if ids.iter().any(|id| !s.doc.occurrences.iter().any(|o| o.id == *id)) { + return Err(bad("component.revert_position", "no occurrence")); + } + for id in ids { + s.pending_moves.remove(&id); + } + } else { + s.pending_moves.clear(); + } + let n = before - s.pending_moves.len(); s.revision += 1; Ok(json!({ "reverted": n })) } @@ -374,7 +473,7 @@ pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) { if s.active_component == 0 || crate::frames::mapped() { return; } - let m = s.doc.component_transform(s.active_component); + let m = s.active_component_frame(); if solvecraft_doc::is_identity(&m) { return; } diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs new file mode 100644 index 00000000..ad3ed31e --- /dev/null +++ b/crates/engine/src/cmd/component_clone.rs @@ -0,0 +1,623 @@ +//! Independent copies of a component definition graph, assembled and evaluated atomically. +use std::collections::{BTreeMap, BTreeSet}; + +use crate::params::bad; +use crate::{Result, Sel, Session}; +use serde_json::{Value, json}; +use solvecraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence}; + +const CMD: &str = "component.paste_new"; +const MAX_WORK: usize = 10_000; + +fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64], occurrences: &BTreeMap) -> Result> { + let start = path + .iter() + .position(|id| source.occurrences.iter().any(|o| o.id == *id && o.component == root)) + .ok_or_else(|| bad(CMD, "copied Emboss placement is outside the copied component tree"))?; + let mut mapped = destination.to_vec(); + for id in path.iter().skip(start + 1) { + mapped.push(*occurrences.get(id).ok_or_else(|| bad(CMD, "copied Emboss path has an external descendant"))?); + } + if mapped.len() > 64 { + return Err(bad(CMD, "copied Emboss path exceeds placement depth limit")); + } + Ok(mapped) +} + +fn unique(base: &str, used: &mut BTreeSet) -> String { + let stem = format!("{base} (copy)"); + let mut name = stem.clone(); + let mut n = 2; + while used.contains(&name) { + name = format!("{stem} {n}"); + n += 1; + } + used.insert(name.clone()); + name +} + +fn graph(doc: &Document, root: u64) -> Result> { + let definitions: BTreeSet<_> = doc.components.iter().map(|c| c.id).collect(); + let mut children: BTreeMap> = BTreeMap::new(); + for occurrence in &doc.occurrences { + children.entry(occurrence.parent).or_default().push(occurrence.component); + } + let mut output = Vec::new(); + let mut seen = BTreeSet::new(); + let mut stack = vec![(root, Vec::::new())]; + let mut work = 0; + while let Some((id, mut ancestors)) = stack.pop() { + work += 1; + if work > MAX_WORK || ancestors.len() >= 64 { + return Err(bad(CMD, "component graph exceeds copy work limit")); + } + if ancestors.contains(&id) { + return Err(bad(CMD, "component graph contains a cycle")); + } + if !definitions.contains(&id) { + return Err(bad(CMD, "component graph has a missing definition")); + } + if !seen.insert(id) { + continue; + } + output.push(id); + ancestors.push(id); + if let Some(children) = children.get(&id) { + if work.saturating_add(stack.len()).saturating_add(children.len()) > MAX_WORK { + return Err(bad(CMD, "component graph exceeds copy work limit")); + } + for child in children.iter().rev() { + stack.push((*child, ancestors.clone())); + } + } + } + // A shared node may be reached by a longer path after its first DFS visit. Check the + // longest path over the complete DAG, rather than trusting the first placement depth. + let mut indegree: BTreeMap = output.iter().map(|id| (*id, 0)).collect(); + for parent in &output { + if let Some(edges) = children.get(parent) { + for child in edges { + if let Some(degree) = indegree.get_mut(child) { + *degree += 1; + } + } + } + } + let mut ready = std::collections::VecDeque::from([root]); + let mut depth = BTreeMap::from([(root, 1usize)]); + let mut sorted = Vec::new(); + while let Some(parent) = ready.pop_front() { + sorted.push(parent); + let parent_depth = *depth.get(&parent).unwrap_or(&1); + if let Some(edges) = children.get(&parent) { + for child in edges { + let next = parent_depth + 1; + if next > 64 { + return Err(bad(CMD, "component graph exceeds copy depth limit")); + } + depth.entry(*child).and_modify(|d| *d = (*d).max(next)).or_insert(next); + if let Some(degree) = indegree.get_mut(child) { + *degree = degree.saturating_sub(1); + if *degree == 0 { + ready.push_back(*child); + } + } + } + } + } + if sorted.len() != output.len() { + return Err(bad(CMD, "component graph contains a cycle")); + } + Ok(sorted) +} + +pub(super) fn paste( + s: &mut Session, + component: u64, + source_occurrence: Option, + transform: Mat, + parent: u64, + params: &Value, +) -> Result { + let source = s.doc.clone(); + let source_parameters: BTreeMap<_, _> = source.params.iter().map(|p| (p.name.as_str(), p)).collect(); + let generated_parameters: BTreeSet<_> = source + .params + .iter() + .filter(|p| p.model) + .map(|p| p.name.as_str()) + .chain(source.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str))) + .collect(); + let definitions = graph(&source, component)?; + let definition_records: BTreeMap<_, _> = source.components.iter().map(|c| (c.id, c)).collect(); + let selected: BTreeSet<_> = definitions.iter().copied().collect(); + if source.marker.is_some_and(|m| m < source.features.len()) { + return Err(bad(CMD, "move the timeline marker to the end before Paste New")); + } + let feats: Vec = source.features.iter().filter(|f| selected.contains(&f.component)).cloned().collect(); + if feats.len() + source.features.len() > MAX_WORK { + return Err(bad(CMD, "too many features to copy")); + } + for f in &feats { + if s.model.result(f.id).is_some_and(|r| r.error.is_some()) { + return Err(bad(CMD, format!("source feature {} has an evaluation error", f.name))); + } + } + let state = s.model.state(); + if state.bodies.len() > MAX_WORK { + return Err(bad(CMD, "body set exceeds copy work limit")); + } + if source.next_id.checked_add(100_000).is_none() { + return Err(bad(CMD, "copy id allocation exceeds supported range")); + } + let mut scratch = s.scratch(); + let mut components = BTreeMap::new(); + let mut occurrences = BTreeMap::new(); + let mut features = BTreeMap::new(); + let mut names = BTreeMap::new(); + let mut bodies = BTreeMap::new(); + let mut parameters = BTreeMap::new(); + let mut used: BTreeSet = source + .features + .iter() + .map(|f| f.name.clone()) + .chain(source.components.iter().map(|c| c.name.clone())) + .chain(state.bodies.iter().map(|b| b.name.clone())) + .collect(); + let mut new_ids = Vec::new(); + let mut frozen = Vec::new(); + // Allocate definitions, discarding their automatic placeholder occurrence except root. + for old in &definitions { + let c = definition_records.get(old).ok_or_else(|| bad(CMD, "missing definition"))?; + let mapped_parent = if *old == component { + parent + } else { + components.get(&c.parent).copied().unwrap_or(*components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?) + }; + let name = unique(&c.name, &mut used); + let new = scratch.doc_mut().add_component(Some(&name), mapped_parent)?; + components.insert(*old, new); + if *old != component { + scratch.doc_mut().occurrences.retain(|o| o.component != new); + } + } + let root = *components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?; + let root_occurrence = scratch.doc.occurrence_of(root).map(|o| o.id).ok_or_else(|| bad(CMD, "missing cloned placement"))?; + let mut clone_path = if let Some(o) = &source_occurrence { + let mut path = super::component::occurrence_path(s, params, o.id, CMD)?; + path.pop(); + path + } else { + source.first_path(parent) + }; + clone_path.push(root_occurrence); + if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) { + o.transform = transform; + o.grounded = source_occurrence.as_ref().is_some_and(|o| o.grounded); + } + if let Some(o) = &source_occurrence { + occurrences.insert(o.id, root_occurrence); + } + for old in source.occurrences.iter().filter(|o| selected.contains(&o.parent)) { + let child = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing child definition"))?; + let owner = *components.get(&old.parent).ok_or_else(|| bad(CMD, "missing parent definition"))?; + let pose = s.pending_moves.get(&old.id).copied().unwrap_or(old.transform); + if !super::component::valid_position(&pose) { + return Err(bad(CMD, "invalid internal pending occurrence transform")); + } + let id = scratch.doc_mut().add_occurrence(child, owner, pose)?; + if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == id) { + o.grounded = old.grounded; + } + occurrences.insert(old.id, id); + } + // Collect historical outputs too: a later Combine may have consumed a named tool body. + let mut outputs: BTreeMap> = BTreeMap::new(); + for f in &feats { + if let Some(result) = s.model.result(f.id) { + let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect(); + for name in &created { + bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used)); + } + outputs.insert(f.id, created); + } + for name in &f.body_names { + if !name.is_empty() { + bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used)); + } + } + if bodies.len() > MAX_WORK { + return Err(bad(CMD, "historical body set exceeds copy work limit")); + } + } + // Body assignment may put externally authored geometry into this graph. Freeze it explicitly. + let owned_features: BTreeSet<_> = feats.iter().map(|f| f.id).collect(); + for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b)) && !owned_features.contains(&b.feature)) { + if body.body.is_mesh() { + return Err(bad(CMD, "Paste New of a reassigned mesh body is not supported yet")); + } + let offset = source.model_body_offset(body).copied().unwrap_or(solvecraft_doc::IDENTITY); + // An external transfer establishes the local baseline in this graph. + // Freeze AFTER that boundary, then replay it as an identity ownership event. + let entry = feats.iter().find(|f| { + !f.suppressed + && matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. } + if !selected.contains(from) && bodies.contains(&body.name) + && s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none() + && result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to))) + }); + let before = feats.iter().find_map(|f| { + let before = s.model.state_before(f.id); + let prior = before.body(&body.name)?; + let after = s.model.result(f.id)?.state.body(&body.name); + if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None } + }); + let entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone()); + let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body); + let baseline_component = source.model_body_component(base); + let geometry = solvecraft_kernel::transform_matrix(&base.body, offset)?; + let step = solvecraft_kernel::step_export(&[&geometry], "SolveCraft independent reassigned body")?; + let new_name = unique(&body.name, &mut used); + bodies.insert(body.name.clone(), new_name.clone()); + let feature_name = unique(&format!("{} Base", body.name), &mut used); + let id = scratch.doc_mut().add_feature( + FeatureKind::Import { file: format!("{} independent base", body.name), step, components: Vec::new() }, + Some(&feature_name), + )?; + if let Some(f) = scratch.doc_mut().feature_mut(id) { + f.component = *components.get(&baseline_component).ok_or_else(|| bad(CMD, "reassigned baseline is outside copied component graph"))?; + f.body_names = vec![new_name]; + } + new_ids.push(id); + frozen.push(body.name.clone()); + } + // Allocate all history IDs before rewriting any forward reference. + for f in &feats { + let name = unique(&f.name, &mut used); + let id = scratch.doc_mut().add_feature(f.kind.clone(), Some(&name))?; + if let Some(new) = scratch.doc.feature(id) { + for (old, pn) in f.param_names.iter().zip(&new.param_names) { + parameters.insert(old.clone(), pn.clone()); + } + } + features.insert(f.id, id); + names.insert(f.name.clone(), name); + new_ids.push(id); + } + // Constraint dimensions own independent generated parameters; public user parameters stay shared. + for f in &feats { + if let FeatureKind::Sketch { sketch, .. } = &f.kind { + for c in &sketch.constraints { + if let Some(name) = &c.param + && !parameters.contains_key(name) + && let Some(p) = source_parameters.get(name.as_str()) + && p.model + { + if scratch.doc.params.len() >= solvecraft_doc::MAX_PARAMS { + return Err(bad(CMD, "too many copied parameters")); + } + let new = scratch.doc_mut().new_model_param(&p.expr, &p.unit); + parameters.insert(name.clone(), new); + } + } + } + } + for (old, new) in ¶meters { + if let Some(comment) = source.param_comments.get(old) { + scratch.doc_mut().param_comments.insert(new.clone(), comment.clone()); + } + if let Some(p) = source_parameters.get(old.as_str()) + && p.model + { + let expr = solvecraft_doc::clone_expression(&p.expr, ¶meters); + if let Some(p) = scratch.doc_mut().params.iter_mut().find(|p| p.name == *new) { + p.expr = expr; + p.comment = source_parameters.get(old.as_str()).map(|p| p.comment.clone()).unwrap_or_default(); + } + } + } + for old in &feats { + let mut f = old.clone(); + f.id = *features.get(&old.id).ok_or_else(|| bad(CMD, "missing feature mapping"))?; + f.name = names.get(&old.name).cloned().ok_or_else(|| bad(CMD, "missing feature name"))?; + f.component = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing component mapping"))?; + if f.body_names.is_empty() { + f.body_names = outputs.get(&old.id).cloned().unwrap_or_default(); + } + f.remap_clone(&features, &names, &bodies, ¶meters)?; + if let FeatureKind::BodyTransfer { from, to, transform, .. } = &mut f.kind { + *to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?; + if let Some(mapped) = components.get(from) { + *from = *mapped; + } else { + *from = *to; + *transform = solvecraft_doc::IDENTITY; + } + } + if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind + && !face.path.is_empty() + && let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind + && bodies.contains_key(&old_face.body) + { + face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?; + } + if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind + && !face.author_path.is_empty() + { + face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?; + } + if let (FeatureKind::Sketch { plane, .. }, FeatureKind::Sketch { plane: old_plane, .. }) = (&mut f.kind, &old.kind) + && let (Some(anchor), Some(original)) = (super::sketch::face::anchor_mut(plane), super::sketch::face::anchor(old_plane)) + { + if bodies.contains_key(&original.target.body) { + anchor.component = + *components.get(&original.component).ok_or_else(|| bad(CMD, "anchored source component is outside copied graph"))?; + anchor.path = cloned_path(&source, &original.path, component, &clone_path, &occurrences)?; + } + anchor.author_path = cloned_path(&source, &original.author_path, component, &clone_path, &occurrences)?; + } + if let Some(slot) = scratch.doc_mut().feature_mut(f.id) { + *slot = f; + } + } + for (old, new) in &bodies { + if let Some(mat) = source.materials.get(old) { + scratch.doc_mut().materials.insert(new.clone(), mat.clone()); + } + if let Some(look) = source.appearances.bodies.get(old) { + scratch.doc_mut().appearances.bodies.insert(new.clone(), look.clone()); + } + if !frozen.contains(old) { + if let Some(comp) = source.body_components.get(old).and_then(|c| components.get(c)) { + scratch.doc_mut().body_components.insert(new.clone(), *comp); + } + if let Some(offset) = source.body_offsets.get(old) { + scratch.doc_mut().body_offsets.insert(new.clone(), *offset); + } + } + } + for (old, new) in &components { + if let Some(part) = source.parts.get(old) { + scratch.doc_mut().parts.insert(*new, part.clone()); + } + if let Some(look) = source.appearances.components.get(old) { + scratch.doc_mut().appearances.components.insert(*new, look.clone()); + } + } + // A creator's history can belong here while its final body was reassigned outside. + // Retain the independent history, but reproduce the current ownership at its end. + let moved_out: Vec<_> = state + .bodies + .iter() + .filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.model_body_component(b))) + .filter_map(|b| bodies.get(&b.name).cloned()) + .collect(); + if !moved_out.is_empty() { + let name = unique("Assigned-out bodies", &mut used); + let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out }, Some(&name))?; + if let Some(f) = scratch.doc_mut().feature_mut(id) { + f.component = root; + } + new_ids.push(id); + } + for face in &source.appearances.faces { + if let Some(new) = bodies.get(&face.body) { + let mut face = face.clone(); + if frozen.contains(&face.body) { + if face.strict_name { + return Err(bad(CMD, "cannot copy strict face paint through uncertified foreign re-export")); + } + if let Some(name) = &face.name { + face.point = solvecraft_doc::naming::point_on_face(&state, name, face.point) + .map(|(point, _)| point) + .ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?; + } + let source_body = state.body(&face.body).ok_or_else(|| bad(CMD, "source frozen face appearance lost its body"))?; + face.point = solvecraft_doc::apply_point(source.model_body_offset(source_body).unwrap_or(&solvecraft_doc::IDENTITY), face.point); + face.name = None; + } else if let Some(name) = &mut face.name { + *name = solvecraft_doc::remap_topology(name, &features, &bodies); + } + face.body = new.clone(); + if let Some(certificate) = &mut face.certificate { + certificate.remap_features(&features); + } + scratch.doc_mut().appearances.faces.push(face); + } + } + // Typed configuration and assembly helpers preserve original targets. + scratch.doc_mut().configs.clone_targets(&solvecraft_doc::config::ConfigCloneMaps { + features: features.clone(), + components: components.clone(), + bodies: bodies.clone(), + parameters: parameters.clone(), + })?; + let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), &source, &occurrences, &features, &bodies)?; + let mut reference_work = 0usize; + for feature in &feats { + if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind + && !face.path.is_empty() + && !bodies.contains_key(&face.body) + { + notices.push(format!("{} retains external selected occurrence path {:?}", feature.name, face.path)); + } + if let FeatureKind::Sketch { sketch, .. } = &feature.kind { + use solvecraft_sketch::LinkSource as L; + let mut pending: Vec<_> = sketch.links.iter().map(|l| &l.source).collect(); + let mut work = 0; + while let Some(link) = pending.pop() { + work += 1; + reference_work += 1; + if work > MAX_WORK || reference_work > 100_000 { + return Err(bad(CMD, "linked references exceed copy work limit")); + } + match link { + L::Edge { body, .. } + | L::Face { body, .. } + | L::Body { body } + | L::Vertex { body, .. } + | L::Iso { body, .. } + | L::Spun { body, .. } => { + if !bodies.contains_key(body) { + notices.push(format!("{} retains external linked body {body}", feature.name)); + } + } + L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } => { + if !features.contains_key(sketch) { + notices.push(format!("{} retains external linked sketch {sketch}", feature.name)); + } + } + L::OnSurface { sketch, body, .. } => { + if !features.contains_key(sketch) || !bodies.contains_key(body) { + notices.push(format!("{} retains external surface link", feature.name)); + } + } + L::Intersection { a, b } => { + pending.push(a); + pending.push(b); + } + L::Plane { name } if source.find_feature(name).is_some() && !names.contains_key(name) => { + notices.push(format!("{} retains external linked plane {name}", feature.name)); + } + _ => {} + } + } + } + if let FeatureKind::Sketch { plane, .. } = &feature.kind + && let Some(anchor) = super::sketch::face::anchor(plane) + && !bodies.contains_key(&anchor.target.body) + { + notices.push(format!( + "{} retains external exact face attachment to {} (producer {})", + feature.name, anchor.target.body, anchor.target.origin_feature + )); + } + for id in feature.kind.input_sketches() { + if !features.contains_key(&id) { + notices.push(format!("{} retains an external sketch reference to {id}", feature.name)); + } + } + if let FeatureKind::Pattern { features: references, .. } | FeatureKind::Mirror { features: references, .. } = &feature.kind { + for name in references { + if !names.contains_key(name) { + notices.push(format!("{} retains external feature reference {name}", feature.name)); + } + } + } + for expression in feature.kind.expressions() { + for name in solvecraft_doc::expr::references(expression) { + reference_work += 1; + if reference_work > 100_000 { + return Err(bad(CMD, "expression references exceed copy work limit")); + } + if !parameters.contains_key(&name) && generated_parameters.contains(name.as_str()) { + notices.push(format!("{} retains external model parameter {name}", feature.name)); + } + } + } + } + notices.sort(); + notices.dedup(); + scratch.refresh(); + let original_errors: BTreeMap<_, _> = s.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect(); + let refreshed_errors: BTreeMap<_, _> = scratch.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect(); + if source.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) { + return Err(bad(CMD, "copy would change an original feature's error state")); + } + // Base imports acquire new face identities. Resolve copied face overrides against the + // verified new geometry once, rather than weakening strict persistent-name semantics. + let resolved = scratch.model.state(); + let mut face_updates = Vec::new(); + for (index, face) in scratch.doc.appearances.faces.iter().enumerate() { + if bodies.iter().any(|(old, new)| frozen.contains(old) && *new == face.body) { + let body = resolved.body(&face.body).ok_or_else(|| bad(CMD, "missing frozen face body"))?; + let face_index = + solvecraft_doc::appearance::face_index_at(body, face.point).ok_or_else(|| bad(CMD, "cannot reproduce frozen face appearance"))?; + let name = solvecraft_doc::naming::face_names(body).get(face_index).cloned().ok_or_else(|| bad(CMD, "missing frozen face identity"))?; + face_updates.push((index, name)); + } + } + for (index, name) in face_updates { + if let Some(face) = scratch.doc_mut().appearances.faces.get_mut(index) { + face.name = Some(name); + } + } + for id in &new_ids { + if let Some(error) = scratch.model.result(*id).and_then(|r| r.error.as_ref()) { + return Err(bad(CMD, format!("copied feature failed: {error}"))); + } + } + let copied = scratch.model.state(); + let copied_bodies: BTreeMap<_, _> = copied.bodies.iter().map(|body| (body.name.as_str(), body)).collect(); + // Adding placements can invalidate cached source evaluation. Compare clone effects + // against that same evaluation's source prefix, not older shared B-rep pointers. + if scratch.doc.features.get(..source.features.len()) != Some(source.features.as_slice()) + || scratch.doc.params.get(..source.params.len()) != Some(source.params.as_slice()) + { + return Err(bad(CMD, "copy would change original feature history or parameters")); + } + let original_state = new_ids.first().map(|id| scratch.model.state_before(*id)).unwrap_or_else(|| copied.clone()); + let all_columns = std::mem::take(&mut scratch.doc_mut().configs.columns); + scratch.doc_mut().configs.columns = all_columns.iter().skip(source.configs.columns.len()).cloned().collect(); + let style_validation = scratch.doc.configuration_style_status(&copied); + scratch.doc_mut().configs.columns = all_columns; + style_validation?; + let mut proof_work = 0usize; + let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))).count(); + let copied_components: BTreeSet<_> = components.values().copied().collect(); + let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.model_body_component(b))).count(); + if actual_count != expected_count { + return Err(bad(CMD, "copy produced an incomplete or unexpected set of bodies")); + } + for original in &original_state.bodies { + let Some(after) = copied_bodies.get(original.name.as_str()) else { + return Err(bad(CMD, "copy would remove a source body outside its independent history")); + }; + if !original.body.same(&after.body) { + return Err(bad(CMD, "copy would modify a source body; external destructive dependencies are not supported")); + } + } + for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))) { + let new = bodies.get(&body.name).ok_or_else(|| bad(CMD, format!("body {} has no clone identity", body.name)))?; + let cloned = copied_bodies.get(new.as_str()).ok_or_else(|| bad(CMD, format!("copy did not reproduce body {}", body.name)))?; + let a = solvecraft_kernel::transform_matrix(&body.body, *source.model_body_offset(body).unwrap_or(&solvecraft_doc::IDENTITY))?; + let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.model_body_offset(cloned).unwrap_or(&solvecraft_doc::IDENTITY))?; + let ma = solvecraft_kernel::measure(&a)?; + let mb = solvecraft_kernel::measure(&b)?; + let tolerance = 1e-5 * (1.0 + (ma.bbox.max - ma.bbox.min).len()) + 64.0 * f64::EPSILON * (ma.bbox.min.len() + ma.bbox.max.len()); + let mut material_matches = true; + let mesh_a = a.display_mesh((a.size() * 1e-3).max(1e-3))?; + let mesh_b = b.display_mesh((b.size() * 1e-3).max(1e-3))?; + proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27)); + if proof_work > 50_000_000 { + return Err(bad(CMD, "geometry verification exceeds copy work limit")); + } + for x in [0.2, 0.5, 0.8] { + for y in [0.2, 0.5, 0.8] { + for z in [0.2, 0.5, 0.8] { + let d = ma.bbox.max - ma.bbox.min; + let p = ma.bbox.min + solvecraft_geom::Vec3::new(d.x * x, d.y * y, d.z * z); + material_matches &= mesh_a.contains(p) == mesh_b.contains(p); + } + } + } + if (ma.volume - mb.volume).abs() > 1e-5 * (1.0 + ma.volume.abs()) + || (ma.area - mb.area).abs() > 1e-5 * (1.0 + ma.area.abs()) + || ma.centroid.dist(mb.centroid) > tolerance + || ma.bbox.min.dist(mb.bbox.min) > tolerance + || ma.bbox.max.dist(mb.bbox.max) > tolerance + || !material_matches + { + return Err(bad(CMD, format!("copy geometry differs for body {}", body.name))); + } + } + scratch.selection = vec![Sel::Occurrence { path: clone_path }]; + s.accept_computed(scratch); + Ok( + json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices}), + ) +} + +#[cfg(test)] +#[path = "component_clone_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/component_clone_assembly.rs b/crates/engine/src/cmd/component_clone_assembly.rs new file mode 100644 index 00000000..22a186fc --- /dev/null +++ b/crates/engine/src/cmd/component_clone_assembly.rs @@ -0,0 +1,360 @@ +//! Typed remapping of assembly relationships for an independent component-tree copy. + +use std::collections::{BTreeMap, BTreeSet}; + +use solvecraft_doc::Document; +use solvecraft_doc::joints::{Assembly, JointOrigin, Snap}; + +use crate::{EngineError, Result}; + +const MAX_RECORDS: usize = 10_000; +const MAX_REFERENCES: usize = 100_000; +const MAX_TEXT: usize = 1_048_576; +const MAX_NAME: usize = 8192; + +fn invalid(message: &str) -> EngineError { + EngineError::Other(format!("Paste New assembly: {message}")) +} + +fn add_references(count: &mut usize, more: usize) -> Result<()> { + *count = count.saturating_add(more); + if *count > MAX_REFERENCES { + return Err(invalid("assembly references exceed the processing budget")); + } + Ok(()) +} + +fn finite_origin(origin: &JointOrigin) -> Result<()> { + let finite = match &origin.snap { + Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()), + Snap::FaceCenter { pick, .. } | Snap::CircleCenter { pick, .. } => pick.is_finite(), + Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()), + }; + if !finite { + return Err(invalid("joint origin contains non-finite geometry")); + } + Ok(()) +} + +fn budget(assembly: &Assembly) -> Result<()> { + let counts = [ + assembly.origins.len(), + assembly.joints.len(), + assembly.links.len(), + assembly.contact_sets.len(), + assembly.studies.len(), + assembly.exploded.len(), + ]; + if counts.iter().any(|n| *n > MAX_RECORDS) { + return Err(invalid("too many assembly records")); + } + let mut references = counts.iter().fold(0usize, |n, c| n.saturating_add(*c)); + let mut bytes = 0usize; + let mut text = |s: &str| -> Result<()> { + bytes = bytes.saturating_add(s.len()); + if s.len() > MAX_NAME || bytes > MAX_TEXT { + return Err(invalid("assembly names exceed the processing budget")); + } + Ok(()) + }; + for origin in &assembly.origins { + finite_origin(&origin.origin)?; + text(&origin.name)?; + if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.origin.snap { + text(face)?; + } + } + for joint in &assembly.joints { + text(&joint.name)?; + add_references(&mut references, joint.values.len().saturating_add(joint.limits.len()))?; + if !joint.values.iter().all(|v| v.is_finite()) + || !joint.limits.iter().flatten().all(|(lo, hi)| lo.is_finite() && hi.is_finite() && lo <= hi) + || !joint.offset.is_finite() + || !joint.angle.is_finite() + { + return Err(invalid("joint contains invalid motion values")); + } + for origin in [&joint.a, &joint.b] { + finite_origin(origin)?; + if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.snap { + text(face)?; + } + } + } + for contact in &assembly.contact_sets { + text(&contact.name)?; + add_references(&mut references, contact.occurrences.len())?; + } + for study in &assembly.studies { + text(&study.name)?; + add_references(&mut references, study.keys.len())?; + for key in &study.keys { + add_references(&mut references, key.points.len())?; + if !key.points.iter().all(|(step, value)| step.is_finite() && value.is_finite()) { + return Err(invalid("motion study contains non-finite keys")); + } + } + } + for view in &assembly.exploded { + text(&view.name)?; + add_references(&mut references, view.moves.len())?; + if !view.moves.iter().all(|(_, p)| p.is_finite()) { + return Err(invalid("exploded view contains non-finite moves")); + } + } + if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) { + return Err(invalid("motion link contains non-finite values")); + } + if references > MAX_REFERENCES { + return Err(invalid("assembly references exceed the processing budget")); + } + Ok(()) +} + +fn unique_name(base: &str, used: &mut BTreeSet) -> Result { + if !used.contains(base) { + used.insert(base.to_string()); + return Ok(base.to_string()); + } + for index in 1..=MAX_RECORDS { + let name = if index == 1 { format!("{base} (copy)") } else { format!("{base} (copy {index})") }; + if name.len() > MAX_NAME { + return Err(invalid("copied assembly name is too long")); + } + if used.insert(name.clone()) { + return Ok(name); + } + } + Err(invalid("could not allocate a unique assembly name")) +} + +fn next_id(id: &mut u64) -> Result { + *id = id.checked_add(1).ok_or_else(|| invalid("assembly identifier space is exhausted"))?; + Ok(*id) +} + +fn unique_ids(ids: impl Iterator) -> Result { + let mut seen = BTreeSet::new(); + let mut largest = 0; + for id in ids { + if id == 0 || !seen.insert(id) { + return Err(invalid("assembly identifiers must be nonzero and unique")); + } + largest = largest.max(id); + } + Ok(largest) +} + +fn validate_map(map: &BTreeMap) -> Result<()> { + if map.len() > MAX_RECORDS || map.values().collect::>().len() != map.len() { + return Err(invalid("copy maps exceed their budget or contain duplicate destinations")); + } + Ok(()) +} + +/// Remap certified naming tokens, rather than replacing digit substrings (F1 must not +/// alter F10). Body-based legacy names are remapped only through an explicit identity map. +fn face_name(name: &str, features: &BTreeMap, bodies: &BTreeMap) -> Option { + let body = name.match_indices(':').filter_map(|(end, _)| bodies.get_key_value(name.get(..end)?)).next_back(); + let mut out = String::new(); + let mut last = 0usize; + let mut mapped = false; + if let Some((old, new)) = body { + out.push_str(new); + last = old.len(); + mapped = true; + } + let bytes = name.as_bytes(); + let mut i = last; + while i < bytes.len() { + let boundary = i == 0 + || bytes.get(i.wrapping_sub(1)).is_some_and(|c| matches!(*c, b'@' | b'|')) + || name.get(..i).is_some_and(|prefix| prefix.ends_with(":blend:") || prefix.ends_with(":inner:")); + if bytes.get(i) == Some(&b'F') && boundary { + let mut end = i + 1; + while bytes.get(end).is_some_and(u8::is_ascii_digit) { + end += 1; + } + if end > i + 1 && bytes.get(end).is_some_and(|c| matches!(*c, b':' | b'.')) { + let id: u64 = name.get(i + 1..end)?.parse().ok()?; + let replacement = features.get(&id)?; + out.push_str(name.get(last..i)?); + out.push_str(&format!("F{replacement}")); + last = end; + mapped |= i == 0; + i = end; + continue; + } + } + i += 1; + } + if !mapped { + return None; + } + out.push_str(name.get(last..)?); + Some(out) +} + +fn origin( + origin: &JointOrigin, + occurrences: &BTreeMap, + features: &BTreeMap, + bodies: &BTreeMap, +) -> Option { + let mut out = origin.clone(); + out.occurrence = *occurrences.get(&origin.occurrence)?; + match &mut out.snap { + Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } => *face = face_name(face, features, bodies)?, + _ => {} + } + Some(out) +} + +fn report(messages: &mut Vec, kind: &str, name: &str) { + messages.push(format!("Skipped {kind} `{name}`: references outside the copied component tree or geometry without a copied identity.")); +} + +/// Called with a scratch document after geometry cloning. Only wholly internal records +/// are copied; cross-boundary relationships are reported and omitted without truncation. +/// The assembly is swapped in only after every map, allocation and aggregate check passes. +pub(super) fn clone_assembly( + doc: &mut Document, + source: &Document, + occurrences: &BTreeMap, + features: &BTreeMap, + bodies: &BTreeMap, +) -> Result> { + budget(&source.assembly)?; + budget(&doc.assembly)?; + validate_map(occurrences)?; + validate_map(features)?; + validate_map(bodies)?; + if doc.occurrences.len() > MAX_REFERENCES { + return Err(invalid("too many destination occurrences")); + } + let mut map_bytes = 0usize; + for (old, new) in bodies { + map_bytes = map_bytes.saturating_add(old.len()).saturating_add(new.len()); + if old.is_empty() || new.is_empty() || old.len() > MAX_NAME || new.len() > MAX_NAME || map_bytes > MAX_TEXT { + return Err(invalid("invalid or excessive body identity map")); + } + } + let available: BTreeSet = doc.occurrences.iter().map(|o| o.id).chain(std::iter::once(0)).collect(); + if occurrences.values().any(|id| !available.contains(id)) { + return Err(invalid("copy occurrence map points to a missing occurrence")); + } + unique_ids(source.assembly.joints.iter().map(|j| j.id))?; + let mut joint_id = unique_ids(doc.assembly.joints.iter().map(|j| j.id))?; + let aux = |a: &Assembly| { + a.contact_sets.iter().map(|x| x.id).chain(a.studies.iter().map(|x| x.id)).chain(a.exploded.iter().map(|x| x.id)).collect::>() + }; + unique_ids(aux(&source.assembly).into_iter())?; + let mut auxiliary_id = unique_ids(aux(&doc.assembly).into_iter())?; + let mut a = doc.assembly.clone(); + let mut messages = Vec::new(); + let mut origin_names = a.origins.iter().map(|o| o.name.clone()).collect(); + for old in &source.assembly.origins { + if !occurrences.contains_key(&old.origin.occurrence) { + continue; + } + if let Some(new) = origin(&old.origin, occurrences, features, bodies) { + let mut item = old.clone(); + item.name = unique_name(&old.name, &mut origin_names)?; + item.origin = new; + a.origins.push(item); + } else { + report(&mut messages, "joint origin", &old.name); + } + } + let mut joint_names = a.joints.iter().map(|j| j.name.clone()).collect(); + let mut joints = BTreeMap::new(); + let mut touched_joints = BTreeSet::new(); + for old in &source.assembly.joints { + if !occurrences.contains_key(&old.a.occurrence) && !occurrences.contains_key(&old.b.occurrence) { + continue; + } + touched_joints.insert(old.id); + match (origin(&old.a, occurrences, features, bodies), origin(&old.b, occurrences, features, bodies)) { + (Some(aa), Some(bb)) => { + let mut item = old.clone(); + item.id = next_id(&mut joint_id)?; + item.name = unique_name(&old.name, &mut joint_names)?; + item.a = aa; + item.b = bb; + joints.insert(old.id, item.id); + a.joints.push(item); + } + _ => report(&mut messages, "joint", &old.name), + } + } + for old in &source.assembly.links { + if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) { + continue; + } + match (joints.get(&old.a), joints.get(&old.b)) { + (Some(aa), Some(bb)) => { + let mut item = old.clone(); + item.a = *aa; + item.b = *bb; + a.links.push(item); + } + _ => report(&mut messages, "motion link", &format!("{} → {}", old.a, old.b)), + } + } + let mut contact_names = a.contact_sets.iter().map(|x| x.name.clone()).collect(); + for old in &source.assembly.contact_sets { + if !old.occurrences.iter().any(|o| occurrences.contains_key(o)) { + continue; + } + if let Some(ids) = old.occurrences.iter().map(|o| occurrences.get(o).copied()).collect::>>() { + let mut item = old.clone(); + item.id = next_id(&mut auxiliary_id)?; + item.name = unique_name(&old.name, &mut contact_names)?; + item.occurrences = ids; + a.contact_sets.push(item); + } else { + report(&mut messages, "contact set", &old.name); + } + } + let mut study_names = a.studies.iter().map(|x| x.name.clone()).collect(); + for old in &source.assembly.studies { + if !old.keys.iter().any(|k| touched_joints.contains(&k.joint)) { + continue; + } + if old.keys.iter().all(|k| joints.contains_key(&k.joint)) { + let mut item = old.clone(); + item.id = next_id(&mut auxiliary_id)?; + item.name = unique_name(&old.name, &mut study_names)?; + for key in &mut item.keys { + key.joint = *joints.get(&key.joint).ok_or_else(|| invalid("missing copied study joint"))?; + } + a.studies.push(item); + } else { + report(&mut messages, "motion study", &old.name); + } + } + let mut view_names = a.exploded.iter().map(|x| x.name.clone()).collect(); + for old in &source.assembly.exploded { + if !old.moves.iter().any(|(o, _)| occurrences.contains_key(o)) { + continue; + } + if old.moves.iter().all(|(o, _)| occurrences.contains_key(o)) { + let mut item = old.clone(); + item.id = next_id(&mut auxiliary_id)?; + item.name = unique_name(&old.name, &mut view_names)?; + for (o, _) in &mut item.moves { + *o = *occurrences.get(o).ok_or_else(|| invalid("missing copied exploded occurrence"))?; + } + a.exploded.push(item); + } else { + report(&mut messages, "exploded view", &old.name); + } + } + budget(&a)?; + doc.assembly = a; + Ok(messages) +} + +#[cfg(test)] +#[path = "component_clone_assembly_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/component_clone_assembly_tests.rs b/crates/engine/src/cmd/component_clone_assembly_tests.rs new file mode 100644 index 00000000..99a6e57d --- /dev/null +++ b/crates/engine/src/cmd/component_clone_assembly_tests.rs @@ -0,0 +1,193 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use super::*; +use solvecraft_doc::joints::{Joint, JointKind, MotionLink, NamedOrigin}; +use solvecraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey}; +use solvecraft_doc::{IDENTITY, Occurrence}; +use solvecraft_geom::Vec3; + +fn at(occurrence: u64) -> JointOrigin { + JointOrigin { occurrence, snap: Snap::Point { point: Vec3::ZERO, z: None } } +} + +fn joint(id: u64, a: u64, b: u64) -> Joint { + Joint { + id, + name: format!("Joint{id}"), + kind: JointKind::Revolute, + a: at(a), + b: at(b), + values: vec![0.25], + limits: vec![Some((-1.0, 1.0))], + flip: true, + offset: 2.0, + angle: 0.1, + suppressed: false, + } +} + +fn occurrence(id: u64) -> Occurrence { + Occurrence { id, component: id, parent: 0, name: format!("Occurrence{id}"), transform: IDENTITY, grounded: false } +} + +fn documents() -> (Document, Document) { + let mut source = Document { occurrences: [1, 2, 8, 9].map(occurrence).to_vec(), ..Document::default() }; + source.assembly.joints = vec![joint(1, 1, 2), joint(2, 1, 2), joint(3, 2, 9), joint(4, 8, 9)]; + source.assembly.origins = vec![ + NamedOrigin { + name: "Typed origin".into(), + origin: JointOrigin { occurrence: 1, snap: Snap::FaceCenter { pick: Vec3::new(1.0, 2.0, 3.0), face: Some("F1:start@F10.2".into()) } }, + }, + NamedOrigin { + name: "Foreign geometry".into(), + origin: JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::ZERO, face: Some("F99:end".into()) } }, + }, + NamedOrigin { name: "Outside".into(), origin: at(9) }, + ]; + source.assembly.links = + vec![MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3 }, MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0 }]; + source.assembly.contact_sets = vec![ + ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true }, + ContactSet { id: 2, name: "Cross contact".into(), occurrences: vec![1, 9], suppressed: false }, + ]; + let key = |joint| StudyKey { joint, index: 0, points: vec![(0.0, 0.0), (10.0, 0.5)] }; + source.assembly.studies = vec![ + MotionStudy { id: 3, name: "Internal study".into(), steps: 10, keys: vec![key(1), key(2)] }, + MotionStudy { id: 4, name: "Cross study".into(), steps: 10, keys: vec![key(1), key(3)] }, + ]; + source.assembly.exploded = vec![ + ExplodedView { id: 5, name: "Internal explode".into(), moves: vec![(1, Vec3::X * 10.0), (2, Vec3::Y * 20.0)] }, + ExplodedView { id: 6, name: "Cross explode".into(), moves: vec![(1, Vec3::X), (9, Vec3::Y)] }, + ]; + let mut destination = source.clone(); + destination.occurrences.extend([101, 102].map(occurrence)); + (source, destination) +} + +#[test] +fn internal_relationships_receive_independent_ids_and_remapped_typed_references() { + let (source, mut destination) = documents(); + let report = + clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new()) + .unwrap(); + let a = &destination.assembly; + assert_eq!(a.joints.len(), 6); + assert_eq!((a.joints[4].id, a.joints[5].id), (5, 6)); + assert_eq!((a.joints[4].a.occurrence, a.joints[4].b.occurrence), (101, 102)); + assert_eq!(a.joints[4].values, source.assembly.joints[0].values); + assert_eq!(a.joints[4].limits, source.assembly.joints[0].limits); + assert_eq!(a.joints[4].name, "Joint1 (copy)"); + assert_eq!(a.origins.len(), 4); + assert!(matches!(&a.origins[3].origin.snap, Snap::FaceCenter { face: Some(face), .. } if face == "F11:start@F110.2")); + assert_eq!((a.links[2].a, a.links[2].b, a.links[2].ratio), (5, 6, -2.0)); + assert_eq!(a.contact_sets[2].id, 7); + assert_eq!(a.contact_sets[2].occurrences, vec![101, 102]); + assert!(a.contact_sets[2].suppressed); + assert_eq!(a.studies[2].id, 8); + assert_eq!(a.studies[2].keys.iter().map(|k| k.joint).collect::>(), vec![5, 6]); + assert_eq!(a.exploded[2].id, 9); + assert_eq!(a.exploded[2].moves, vec![(101, Vec3::X * 10.0), (102, Vec3::Y * 20.0)]); + assert_eq!(report.len(), 6); + for kind in ["joint origin", "joint `Joint3`", "motion link", "contact set", "motion study", "exploded view"] { + assert!(report.iter().any(|m| m.contains(kind)), "{kind}: {report:?}"); + } + assert!(!report.iter().any(|m| m.contains("Outside") || m.contains("Joint4"))); + assert_eq!(destination.assembly.joints[..4], source.assembly.joints); + destination.assembly.joints[4].values[0] = 0.75; + assert_eq!(source.assembly.joints[0].values[0], 0.25); +} + +#[test] +fn naming_remaps_exact_tokens_unicode_and_legacy_body_identity() { + let features = BTreeMap::from([(1, 21), (10, 30)]); + let bodies = BTreeMap::from([("Wheel: inner".into(), "Wheel copy: inner".into())]); + assert_eq!(face_name("F1:blend:F10:start@F1.3", &features, &bodies).as_deref(), Some("F21:blend:F30:start@F21.3")); + assert_eq!(face_name("F1:side:曲線F10", &features, &bodies).as_deref(), Some("F21:side:曲線F10")); + assert_eq!(face_name("F1:side:F100.7", &features, &bodies).as_deref(), Some("F21:side:F100.7")); + assert_eq!(face_name("Wheel: inner:face:7", &features, &bodies).as_deref(), Some("Wheel copy: inner:face:7")); + assert!(face_name("F100:start", &features, &bodies).is_none()); + assert!(face_name("F1:start@F99.1", &features, &bodies).is_none()); + assert!(face_name("Foreign:face:0", &features, &bodies).is_none()); + assert!(face_name("Foreign:face:0@F1.1", &features, &bodies).is_none()); + let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]); + assert_eq!(face_name("F1:Housing:face:0@F10.1", &features, &bodies).as_deref(), Some("F1:Housing (copy):face:0@F30.1")); +} + +#[test] +fn malformed_maps_identifier_exhaustion_and_aggregate_budgets_are_atomic() { + let (source, destination) = documents(); + let mut cases = Vec::new(); + cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 101), (2, 101)]))); + cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 999)]))); + let mut overflow = destination.clone(); + overflow.assembly.joints[0].id = u64::MAX; + cases.push((source.clone(), overflow, BTreeMap::from([(1, 101), (2, 102)]))); + let mut oversized = source.clone(); + oversized.assembly.contact_sets[0].occurrences = vec![1; MAX_REFERENCES + 1]; + cases.push((oversized, destination.clone(), BTreeMap::from([(1, 101), (2, 102)]))); + let mut invalid = source.clone(); + invalid.assembly.links[0].ratio = f64::NAN; + cases.push((invalid, destination, BTreeMap::from([(1, 101), (2, 102)]))); + for (source, mut destination, occurrences) in cases { + let before = destination.assembly.clone(); + assert!(clone_assembly(&mut destination, &source, &occurrences, &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new()).is_err()); + assert_eq!(destination.assembly, before); + } +} + +#[test] +fn second_copy_has_unique_names_and_keeps_global_contact_switches_unchanged() { + let (source, mut destination) = documents(); + destination.assembly.contact_enabled = true; + destination.assembly.contact_all = true; + let features = BTreeMap::from([(1, 11), (10, 110)]); + clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &features, &BTreeMap::new()).unwrap(); + destination.occurrences.extend([201, 202].map(occurrence)); + clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 201), (2, 202)]), &features, &BTreeMap::new()).unwrap(); + assert_eq!(destination.assembly.joints[6].name, "Joint1 (copy 2)"); + assert!(destination.assembly.contact_enabled && destination.assembly.contact_all); + let names: BTreeSet<&str> = destination.assembly.origins.iter().map(|x| x.name.as_str()).collect(); + assert_eq!(names.len(), destination.assembly.origins.len()); +} + +#[test] +fn paste_new_internal_motion_study_drives_only_the_copied_assembly_and_reopens() { + use serde_json::{Value, json}; + fn run(s: &mut crate::Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) + } + let mut s = crate::Session::default(); + let parent = run(&mut s, "component.create", json!({"name":"Mechanism"}))["component"].as_u64().unwrap(); + let fixed = run(&mut s, "component.create", json!({"name":"Support"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"SupportBox"})); + run(&mut s, "occurrence.ground", json!({"occurrence":fixed,"grounded":true})); + run(&mut s, "component.activate", json!({"component":parent})); + let moving = run(&mut s, "component.create", json!({"name":"Carriage"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[0,0,5],"body_name":"CarriageBox"})); + run(&mut s, "joint.origin", json!({"name":"Support origin","occurrence":fixed,"point":[0,0,0]})); + let source_joint = run(&mut s, "joint.as_built", json!({"type":"slider","a":fixed,"b":moving,"name":"Rail"}))["joint"].as_u64().unwrap(); + run(&mut s, "contact.create", json!({"name":"Internal contact","occurrences":[fixed,moving]})); + run(&mut s, "motion.study", json!({"name":"Travel","steps":10,"keys":[{"joint":source_joint,"points":[[0,0],[10,6]]}]})); + run( + &mut s, + "explode.create", + json!({"name":"Exploded mechanism","moves":[{"occurrence":fixed,"translate":[10,0,0]},{"occurrence":moving,"translate":[0,10,0]}]}), + ); + let original = s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform; + run(&mut s, "component.activate", json!({"component":"root"})); + let result = run(&mut s, "component.paste_new", json!({"component":parent,"translate":[20,0,0]})); + assert_eq!(result["notices"], json!([])); + assert_eq!(s.doc.assembly.joints.len(), 2); + assert_eq!(s.doc.assembly.origins.len(), 2); + assert_eq!(s.doc.assembly.contact_sets.len(), 2); + assert_eq!(s.doc.assembly.exploded.len(), 2); + let copied_joint = s.doc.assembly.joints.iter().find(|j| j.name == "Rail (copy)").unwrap().id; + let copied_moving = s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().b.occurrence; + assert_ne!(copied_moving, moving); + run(&mut s, "motion.play", json!({"study":"Travel (copy)","step":10})); + assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == source_joint).unwrap().values, vec![0.0]); + assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().values, vec![6.0]); + assert_eq!(s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform, original); + let reopened = Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(reopened.assembly, s.doc.assembly); + assert!(reopened.assembly.studies.iter().any(|m| m.name == "Travel (copy)" && m.keys[0].joint == copied_joint)); +} diff --git a/crates/engine/src/cmd/component_clone_tests.rs b/crates/engine/src/cmd/component_clone_tests.rs new file mode 100644 index 00000000..b0c59425 --- /dev/null +++ b/crates/engine/src/cmd/component_clone_tests.rs @@ -0,0 +1,467 @@ +#![allow(clippy::unwrap_used)] +use crate::Session; +use serde_json::{Value, json}; +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap() +} +fn component(s: &mut Session, name: &str) -> u64 { + run(s, "component.create", json!({"name":name}))["component"].as_u64().unwrap() +} +fn volume(s: &mut Session, id: u64) -> f64 { + run(s, "component.properties", json!({"component":id}))["total"]["volume_mm3"].as_f64().unwrap() +} +fn box2(s: &mut Session) -> u64 { + run(s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Cube"}))["feature"].as_u64().unwrap() +} +fn copy(s: &mut Session, id: u64) -> Value { + run(s, "component.paste_new", json!({"component":id,"translate":[20,0,0]})) +} +fn near(a: f64, b: f64) { + assert!((a - b).abs() < 1e-6, "{a} != {b}"); +} + +#[test] +fn coincident_independent_primitives_keep_their_own_semantic_faces() { + let mut s = Session::default(); + let first = box2(&mut s); + let second = run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Other"}))["feature"].as_u64().unwrap(); + let state = s.model.state(); + let a = state.body("Cube").unwrap(); + let b = state.body("Other").unwrap(); + let a_names = solvecraft_doc::naming::face_names(a); + let b_names = solvecraft_doc::naming::face_names(b); + assert!(a_names.contains(&format!("F{first}:+z"))); + assert!(b_names.contains(&format!("F{second}:+z"))); + assert!(!b_names.contains(&format!("F{first}:+z"))); +} + +#[test] +fn nested_definition_dag_and_histories_are_independent() { + let mut s = Session::default(); + let parent = component(&mut s, "Assembly"); + let child = component(&mut s, "Child"); + let feature = box2(&mut s); + run(&mut s, "occurrence.copy", json!({"component":child})); + let copied = copy(&mut s, parent); + let new = copied["component"].as_u64().unwrap(); + assert_eq!(copied["components"], 2); + near(volume(&mut s, new), 16.0); + let kids: Vec<_> = s.doc.occurrences.iter().filter(|o| o.parent == new).collect(); + assert_eq!(kids.len(), 2); + assert_eq!(kids[0].component, kids[1].component); + assert_ne!(kids[0].component, child); + let parameter = s.doc.feature(feature).unwrap().param_names[0].clone(); + run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"})); + near(volume(&mut s, parent), 24.0); + near(volume(&mut s, new), 16.0); + let saved = s.doc.to_json(); + let reopened = solvecraft_doc::Document::from_json(&saved).unwrap(); + assert_eq!(reopened.occurrences.iter().filter(|o| o.parent == new).count(), 2); +} + +#[test] +fn reassigned_body_is_an_independent_base_feature() { + let mut s = Session::default(); + let feature = box2(&mut s); + let part = component(&mut s, "Part"); + run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]})); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + assert_eq!(copied["frozen_bodies"], json!(["Cube"])); + near(volume(&mut s, new), 8.0); + let parameter = s.doc.feature(feature).unwrap().param_names[0].clone(); + run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"})); + near(volume(&mut s, part), 12.0); + near(volume(&mut s, new), 8.0); +} + +#[test] +fn clone_preserves_physical_material_and_independent_styles() { + let mut s = Session::default(); + let part = component(&mut s, "Part"); + box2(&mut s); + run(&mut s, "material.assign", json!({"bodies":["Cube"],"material":"Brass"})); + run(&mut s, "appearance.assign", json!({"components":[part.to_string()],"color":"#123456","opacity":0.4})); + run(&mut s, "appearance.assign", json!({"bodies":["Cube"],"color":"#abcdef"})); + run(&mut s, "appearance.assign", json!({"body":"Cube","faces":[[1,1,2]],"color":"#00ff00"})); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + let props = run(&mut s, "component.properties", json!({"component":new})); + near(props["total"]["mass_g"].as_f64().unwrap(), 0.068); + let copied_body = s.model.state().bodies.iter().find(|b| b.component == new).unwrap().name.clone(); + assert_eq!(s.doc.appearances.bodies[&copied_body], s.doc.appearances.bodies["Cube"]); + let state = s.model.state(); + let body = state.body(&copied_body).unwrap(); + assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0])); + run(&mut s, "appearance.assign", json!({"bodies":[copied_body],"color":"#ff0000"})); + assert_eq!(s.doc.appearances.bodies["Cube"].color, [0xab, 0xcd, 0xef]); + assert_eq!(s.doc.appearances.components[&new].opacity, 0.4); +} + +#[test] +fn reassigned_body_owned_move_replays_once_and_original_stays_unchanged() { + let mut s = Session::default(); + box2(&mut s); + let part = component(&mut s, "Part"); + run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]})); + run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]})); + let before = run(&mut s, "component.properties", json!({"component":part})); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + let after = run(&mut s, "component.properties", json!({"component":new})); + near(after["total"]["center_of_mass"][0].as_f64().unwrap(), 31.0); + assert_eq!(run(&mut s, "component.properties", json!({"component":part})), before); +} + +#[test] +fn copied_external_destructive_target_is_atomic() { + let mut s = Session::default(); + box2(&mut s); + let part = component(&mut s, "Part"); + run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]})); + let saved = s.doc.to_json(); + let selection = s.selection.clone(); + let active = s.active_component; + let pending = s.pending_moves.clone(); + assert!(s.execute("component.paste_new", &json!({"component":part})).is_err()); + assert_eq!(s.doc.to_json(), saved); + assert_eq!(s.selection, selection); + assert_eq!(s.active_component, active); + assert_eq!(s.pending_moves, pending); +} + +#[test] +fn generated_feature_parameter_dependencies_are_independent() { + let mut s = Session::default(); + let part = component(&mut s, "Part"); + let source = box2(&mut s); + let source_names = s.doc.feature(source).unwrap().param_names.clone(); + run(&mut s, "parameters.change", json!({"name":source_names[1],"expression":format!("{} * 2",source_names[0])})); + let result = copy(&mut s, part); + let clone = result["component"].as_u64().unwrap(); + near(volume(&mut s, clone), 16.0); + let clone_names = s + .doc + .features + .iter() + .find(|f| f.component == clone && matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. })) + .unwrap() + .param_names + .clone(); + assert_ne!(clone_names, source_names); + run(&mut s, "parameters.change", json!({"name":source_names[0],"expression":"3 mm"})); + near(volume(&mut s, part), 36.0); + near(volume(&mut s, clone), 16.0); + run(&mut s, "parameters.change", json!({"name":clone_names[0],"expression":"4 mm"})); + near(volume(&mut s, clone), 64.0); + near(volume(&mut s, part), 36.0); +} + +#[test] +fn selected_nested_pending_placement_survives_copy_and_undo() { + let mut s = Session::default(); + let parent = component(&mut s, "Parent"); + let parent_first = s.doc.occurrence_of(parent).unwrap().id; + let child = component(&mut s, "Child"); + let leaf = s.doc.occurrence_of(child).unwrap().id; + box2(&mut s); + let second = run(&mut s, "occurrence.copy", json!({"component":parent,"translate":[50,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":leaf,"capture":false,"translate":[5,0,0]})); + run(&mut s, "occurrence.move", json!({"occurrence":second,"capture":false,"translate":[2,0,0]})); + let pending = s.pending_moves.clone(); + let prior_context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch); + let copied = run(&mut s, "component.paste_new", json!({"component":child,"occurrence":leaf,"path":[second,leaf],"translate":[10,0,0]})); + let new_leaf = copied["occurrence"].as_u64().unwrap(); + assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), prior_context); + assert_eq!(s.selection, vec![crate::Sel::Occurrence { path: vec![second, new_leaf] }]); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.occurrence_placement(&[parent_first, leaf]).unwrap().world[3][0], 5.0); + let props = run(&mut s, "occurrence.properties", json!({"occurrence":new_leaf,"path":[second,new_leaf]})); + near(props["total"]["center_of_mass"][0].as_f64().unwrap(), 68.0); + run(&mut s, "edit.undo", json!({})); + assert!(!s.doc.occurrences.iter().any(|o| o.id == new_leaf)); + assert_eq!(s.pending_moves, pending); +} + +#[test] +fn copied_sheet_base_targets_its_own_sketch_history() { + let mut s = Session::default(); + let part = component(&mut s, "Sheet"); + let sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Base"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch":"Base"})); + let source_volume = volume(&mut s, part); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + near(volume(&mut s, new), source_volume); + let clone_sketch = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap().id; + assert_ne!(clone_sketch, sketch); + let base = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::SheetBase { .. })).unwrap(); + assert_eq!(base.kind.input_sketches(), vec![clone_sketch]); + // Mutate only source geometry, then verify the copied base remains unchanged. + if let solvecraft_doc::FeatureKind::Sketch { sketch, .. } = &mut s.doc_mut().feature_mut(sketch).unwrap().kind { + for p in &mut sketch.points { + p.pos.x *= 2.0; + } + } + s.refresh(); + near(volume(&mut s, new), source_volume); +} + +#[test] +fn renamed_pattern_outputs_and_later_body_operands_follow_clone() { + let mut s = Session::default(); + let part = component(&mut s, "PatternPart"); + box2(&mut s); + run(&mut s, "solid.pattern.rectangular", json!({"bodies":["Cube"],"dir1":[1,0,0],"count1":3,"spacing1":5})); + let last = s.model.state().bodies.last().unwrap().name.clone(); + run(&mut s, "body.rename", json!({"body":last,"name":"Custom Output"})); + run(&mut s, "solid.move", json!({"bodies":["Custom Output"],"translate":[0,5,0]})); + let copied = copy(&mut s, part); + let new = copied["component"].as_u64().unwrap(); + near(volume(&mut s, new), 24.0); + let state = s.model.state(); + let target = state.bodies.iter().find(|b| b.name == "Custom Output (copy)").unwrap(); + near(solvecraft_kernel::measure(&target.body).unwrap().centroid.y, 6.0); + assert_eq!(s.doc.features.iter().filter(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Move { .. })).count(), 1); +} + +#[test] +fn configuration_rows_follow_copied_parameters_material_and_strict_face_lineage() { + let mut s = Session::default(); + let part = component(&mut s, "Configured"); + let source = box2(&mut s); + let length = s.doc.feature(source).unwrap().param_names[0].clone(); + let target = solvecraft_doc::config::ConfigFaceTarget { + body: "Cube".into(), + origin_feature: source, + name: format!("F{source}:+z"), + point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0), + certificate: None, + }; + run(&mut s, "config.configure", json!({"params":[length],"materials":["Cube"],"component_appearances":[part],"face_appearances":[target]})); + let face_key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target.clone())); + run( + &mut s, + "config.row", + json!({"name":"Large","values":{length.clone():"4 mm","material:Cube":"Brass",face_key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}}), + ); + let result = copy(&mut s, part); + let new = result["component"].as_u64().unwrap(); + assert_eq!(s.doc.configs.columns.len(), 8); + run(&mut s, "config.activate", json!({"row":"Large"})); + near(volume(&mut s, part), 16.0); + near(volume(&mut s, new), 16.0); + let props = run(&mut s, "component.properties", json!({"component":new})); + near(props["total"]["mass_g"].as_f64().unwrap(), 0.136); + let state = s.model.state(); + let body = state.bodies.iter().find(|b| b.component == new).unwrap(); + assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0])); + assert!(s.doc.configuration_style_status(&state).is_ok()); + assert!(s.doc.configuration_matches()); +} + +#[test] +fn configured_extrude_cap_copy_keeps_independent_semantic_lineage() { + let mut s = Session::default(); + let part = component(&mut s, "Extruded"); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,2]})); + run(&mut s, "sketch.finish", json!({})); + let feature = run(&mut s, "solid.extrude", json!({"distance":2}))["feature"].as_u64().unwrap(); + let body_name = s.model.state().bodies.last().unwrap().name.clone(); + let target = solvecraft_doc::config::ConfigFaceTarget { + body: body_name, + origin_feature: feature, + name: format!("F{feature}:top"), + point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0), + certificate: None, + }; + run(&mut s, "config.configure", json!({"face_appearances":[target]})); + let key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target)); + run(&mut s, "config.row", json!({"name":"Painted","values":{key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}})); + let copied = copy(&mut s, part)["component"].as_u64().unwrap(); + run(&mut s, "config.activate", json!({"row":"Painted"})); + let state = s.model.state(); + let body = state.bodies.iter().find(|b| b.component == copied).unwrap(); + assert!(solvecraft_doc::naming::face_names(body).contains(&format!("F{}:top", body.feature))); + assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0])); + assert!(s.doc.configuration_style_status(&state).is_ok()); + let saved = s.doc.to_json(); + let reopened = solvecraft_doc::Document::from_json(&saved).unwrap(); + let mut model = solvecraft_doc::Model::default(); + model.evaluate(&reopened); + assert!(reopened.configuration_style_status(&model.state()).is_ok()); +} + +#[test] +fn selected_second_occurrence_emboss_copy_remaps_target_and_author_paths_and_reopens() { + let mut s = Session::default(); + let part = component(&mut s, "SleevePart"); + let cylinder = run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}))["feature"].as_u64().unwrap(); + let second = run(&mut s, "occurrence.copy", json!({"component":part,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":part,"occurrence":second,"path":[second]})); + run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[80,10,7.5],"depth":1})); + let original_volume = volume(&mut s, part); + let copied = run(&mut s, "component.paste_new", json!({"component":part,"occurrence":second,"path":[second],"translate":[20,0,0]})); + let new = copied["component"].as_u64().unwrap(); + let placement = copied["occurrence"].as_u64().unwrap(); + let emboss = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Emboss { .. })).unwrap(); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &emboss.kind else { panic!("missing selected face") }; + assert_eq!(face.body, "Sleeve (copy)"); + assert_eq!(face.path, vec![placement]); + assert_eq!(face.author_path, vec![placement]); + near(volume(&mut s, new), original_volume); + let clone_volume = volume(&mut s, new); + let height = s.doc.feature(cylinder).unwrap().param_names[1].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"30 mm"})); + near(volume(&mut s, new), clone_volume); + assert!(volume(&mut s, part) > original_volume); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.doc.features.iter().all(|f| reopened.model.result(f.id).is_some_and(|r| r.error.is_none()))); + near(volume(&mut reopened, new), clone_volume); +} + +#[test] +fn reassigned_sample_plate_paste_new_preserves_complex_frozen_geometry() { + let mut s = Session::default(); + s.run_script(&crate::sample::script()).unwrap(); + let body = s.model.state().bodies.first().unwrap().name.clone(); + let created = run(&mut s, "component.from_bodies", json!({"bodies":[body]})); + let part = created["components"][0]["component"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":part})); + let occurrence = s.doc.occurrence_of(part).unwrap().id; + run(&mut s, "occurrence.ground", json!({"occurrence":occurrence,"grounded":true})); + let original_volume = volume(&mut s, part); + let source_mesh = s.model.state().body(&body).unwrap().mesh(); + let boss_probe = solvecraft_geom::Vec3::new(40.0, 35.0, 12.0); + let source_boss = source_mesh.contains(boss_probe); + let copied = run(&mut s, "component.paste_new", json!({"component":part,"translate":[10,10,0]})); + assert_eq!(s.doc.components.len(), 2); + let new = copied["component"].as_u64().unwrap(); + // Imported curved faces may tessellate differently; keep the same geometric + // accuracy required by copy, and independently probe plate and hole material. + assert!((volume(&mut s, new) - original_volume).abs() < 1e-5 * original_volume); + near(volume(&mut s, part), original_volume); + let state = s.model.state(); + let copied_body = state.bodies.iter().find(|body| body.component == new).unwrap(); + let mesh = copied_body.mesh(); + for (point, expected) in + [([5.0, 25.0, 4.0], true), ([12.0, 25.0, 4.0], false), ([68.0, 25.0, 4.0], false), ([40.0, 25.0, 12.0], false), ([0.0, 0.0, 4.0], false)] + { + assert_eq!(mesh.contains(solvecraft_geom::Vec3::new(point[0], point[1], point[2])), expected, "{point:?}"); + } + assert_eq!(mesh.contains(boss_probe), source_boss); + assert_eq!(state.body(&body).unwrap().mesh().contains(boss_probe), source_boss); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!((volume(&mut reopened, new) - original_volume).abs() < 1e-5 * original_volume); +} + +#[test] +fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() { + let mut s = Session::default(); + let part = component(&mut s, "Valid"); + let valid = box2(&mut s); + run(&mut s, "component.activate", json!({"component":0})); + let kind = s.doc.feature(valid).unwrap().kind.clone(); + let broken = s.doc_mut().add_feature(kind, Some("Broken reference")).unwrap(); + let mut probe = (*s.doc).clone(); + let missing = probe.new_model_param("2 mm", "mm"); + let feature = s.doc_mut().feature_mut(broken).unwrap(); + feature.component = 0; + if let solvecraft_doc::FeatureKind::Box { width, .. } = &mut feature.kind { + *width = missing; + } + s.refresh(); + let original_error = s.model.result(broken).unwrap().error.clone(); + assert!(original_error.is_some()); + let saved = s.doc.to_json(); + let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch); + let selection = s.selection.clone(); + let pending = s.pending_moves.clone(); + let error = s.execute("component.paste_new", &json!({"component":part})).unwrap_err(); + assert!(error.to_string().contains("original feature's error state"), "{error}"); + assert_eq!(s.doc.to_json(), saved); + assert_eq!(s.model.result(broken).unwrap().error, original_error); + assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context); + assert_eq!(s.selection, selection); + assert_eq!(s.pending_moves, pending); +} + +#[test] +fn external_transfer_entry_and_internal_rotated_transfer_clone_once() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"})); + let parent = component(&mut s, "Assembly"); + let a = component(&mut s, "A"); + let ao = s.doc.occurrence_of(a).unwrap().id; + run(&mut s, "occurrence.move", json!({"occurrence":ao,"translate":[10,0,0],"angle":90,"axis":[0,0,1],"capture":true})); + run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Foreign"]})); + run(&mut s, "component.activate", json!({"component":parent})); + let b = component(&mut s, "B"); + let bo = s.doc.occurrence_of(b).unwrap().id; + run(&mut s, "occurrence.move", json!({"occurrence":bo,"translate":[0,20,0],"angle":90,"axis":[1,0,0],"capture":true})); + run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Foreign"]})); + run(&mut s, "solid.move", json!({"bodies":["Foreign"],"translate":[0,0,5]})); + let before = s.model.state().body("Foreign").unwrap().mesh().bounds(); + let copied = copy(&mut s, parent); + assert_eq!(copied["frozen_bodies"], json!(["Foreign"])); + let state = s.model.state(); + let body = state.body("Foreign (copy)").unwrap(); + let after = body.mesh().bounds(); + assert!(before.min.dist(after.min) < 1e-6); + assert!(before.max.dist(after.max) < 1e-6); + assert!(body.transferred); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.model.state().body("Foreign (copy)").unwrap().mesh().bounds().min.dist(before.min) < 1e-6); +} + +#[test] +fn suppressed_external_entry_does_not_supply_clone_baseline() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"})); + let part = component(&mut s, "Part"); + let occurrence = s.doc.occurrence_of(part).unwrap().id; + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[25,0,0],"angle":90,"axis":[0,0,1],"capture":true})); + let skipped = s + .doc_mut() + .add_feature( + solvecraft_doc::FeatureKind::BodyTransfer { bodies: vec!["Foreign".into()], from: 0, to: part, transform: solvecraft_doc::IDENTITY }, + None, + ) + .unwrap(); + s.doc_mut().feature_mut(skipped).unwrap().suppressed = true; + s.refresh(); + run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]})); + let before = s.model.state().body("Foreign").unwrap().mesh().bounds(); + copy(&mut s, part); + let after = s.model.state().body("Foreign (copy)").unwrap().mesh().bounds(); + assert!(before.min.dist(after.min) < 1e-6); + assert!(before.max.dist(after.max) < 1e-6); +} + +#[test] +fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Foreign"})); + run(&mut s, "appearance.assign", json!({"body":"Foreign","faces":[[1,1,2]],"color":"#00ff00"})); + s.doc_mut().body_offsets.insert( + "Foreign".into(), + solvecraft_doc::rigid(solvecraft_geom::Vec3::new(7.0, 0.0, 0.0), solvecraft_geom::Vec3::ZERO, solvecraft_geom::Vec3::Z, 0.0), + ); + s.refresh(); + let part = component(&mut s, "Part"); + run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]})); + let original = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign").unwrap(); + let expected = solvecraft_doc::naming::point_on_face(&s.model.state(), original.name.as_ref().unwrap(), original.point).unwrap().0; + copy(&mut s, part); + let paint = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign (copy)").unwrap(); + assert!(paint.point.dist(expected) < 1e-6, "{:?} versus {expected:?}", paint.point); +} diff --git a/crates/engine/src/cmd/component_properties.rs b/crates/engine/src/cmd/component_properties.rs new file mode 100644 index 00000000..3d70262a --- /dev/null +++ b/crates/engine/src/cmd/component_properties.rs @@ -0,0 +1,157 @@ +//! 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. +pub(in crate::cmd) 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)]; + let mut children: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for occurrence in &doc.occurrences { + children.entry(occurrence.parent).or_default().push(occurrence); + } + while let Some((component, path, transform)) = stack.pop() { + for occurrence in children.get(&component).into_iter().flatten() { + 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 path = if p.get("path").is_some() { + super::occurrence_path(s, p, id, cmd)? + } else { + let paths: Vec> = + placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect(); + 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; + let selected_placements = if selected.is_empty() { + if descendants { placements(doc, cmd)? } else { vec![(0, Vec::new(), solvecraft_doc::IDENTITY)] } + } else { + let placement = s.occurrence_placement(selected).map_err(|e| bad(cmd, e.to_string()))?; + let mut out = vec![(placement.component, selected.to_vec(), placement.world)]; + let mut stack = if descendants { out.clone() } else { Vec::new() }; + 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.clone(); + child.push(occurrence.id); + let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform); + out.push((occurrence.component, child.clone(), world)); + stack.push((occurrence.component, child, world)); + } + } + out + }; + for (component, path, transform) in selected_placements { + placement_count += 1; + for body in state.bodies.iter().filter(|b| doc.model_body_component(b) == component) { + let transform: Mat = doc.model_body_offset(body).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/component_transfer.rs b/crates/engine/src/cmd/component_transfer.rs new file mode 100644 index 00000000..be5ae106 --- /dev/null +++ b/crates/engine/src/cmd/component_transfer.rs @@ -0,0 +1,153 @@ +//! Atomic timeline ownership changes, preserving the selected world geometry. +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +type UniqueFrames = BTreeMap, Mat)>>; + +fn unique_frames(s: &Session, cmd: &str) -> Result { + let doc = s.placed_document(); + let mut children = BTreeMap::>::new(); + for occurrence in &doc.occurrences { + children.entry(occurrence.parent).or_default().push(occurrence); + } + let mut frames = BTreeMap::from([(0, Some((Vec::new(), solvecraft_doc::IDENTITY)))]); + let mut stack = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)]; + let mut work = 0usize; + while let Some((component, path, matrix)) = stack.pop() { + for occurrence in children.get(&component).into_iter().flatten() { + work += 1; + if path.len() >= 64 || work > 100_000 { + return Err(bad(cmd, "transfer assembly exceeds 64 levels or 100000 placements")); + } + let mut child_path = path.clone(); + child_path.push(occurrence.id); + let frame = solvecraft_doc::mat_mul(&matrix, &occurrence.transform); + frames.entry(occurrence.component).and_modify(|entry| *entry = None).or_insert_with(|| Some((child_path.clone(), frame))); + stack.push((occurrence.component, child_path, frame)); + } + } + Ok(frames) +} + +fn body_names(s: &Session, p: &Value, cmd: &str, allow_boundary: bool) -> Result> { + if p.get("bodies").and_then(Value::as_array).is_none_or(|names| names.iter().any(|name| !name.is_string())) { + return Err(bad(cmd, "bodies must be an array of body names")); + } + let names = string_list(p, "bodies"); + if names.is_empty() || names.len() > 1000 || names.iter().collect::>().len() != names.len() { + return Err(bad(cmd, "bodies must list 1..1000 distinct existing definition bodies")); + } + if s.doc.marker.is_some() && !allow_boundary { + return Err(bad(cmd, "finish timeline rollback before transferring bodies")); + } + let state = s.model.state(); + for name in &names { + if state.body(name).is_none() { + return Err(bad(cmd, format!("no body `{name}`"))); + } + } + Ok(names) +} + +fn transfer(s: &mut Session, name: &str, to: u64, frames: &UniqueFrames, cmd: &str) -> Result { + let state = s.model.state(); + let body = state.body(name).ok_or_else(|| bad(cmd, "body is missing"))?; + let from = s.doc.model_body_component(body); + let doc = s.placed_document(); + let unique = |component| { + frames.get(&component).and_then(Option::as_ref).ok_or_else(|| { + bad(cmd, "body transfer requires one source and destination placement; shared instances cannot preserve a unique world pose") + }) + }; + let source = unique(from)?; + let destination = unique(to)?; + let old = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&source.1, offset)).unwrap_or(source.1); + let inv = solvecraft_doc::mat_inverse(&destination.1).ok_or_else(|| bad(cmd, "destination frame is not invertible"))?; + let transform = solvecraft_doc::mat_mul(&inv, &old); + if !solvecraft_doc::valid_transfer_matrix(&transform) { + return Err(bad(cmd, "body transfer exceeds supported rigid coordinate range")); + } + let inherited = (!s.doc.appearances.bodies.contains_key(name)).then(|| s.doc.appearances.components.get(&from).cloned()).flatten(); + let paths: BTreeSet = source.0.iter().chain(&destination.0).copied().collect(); + let captures: Vec<_> = paths.into_iter().filter_map(|id| s.pending_moves.get(&id).map(|transform| (id, *transform))).collect(); + if captures.iter().any(|(_, transform)| !solvecraft_doc::valid_transfer_matrix(transform)) { + return Err(bad(cmd, "pending transfer position is not a supported rigid transform")); + } + drop(doc); + for (id, transform) in captures { + if let Some(occurrence) = s.doc_mut().occurrences.iter_mut().find(|occurrence| occurrence.id == id) { + occurrence.transform = transform; + } + s.pending_moves.remove(&id); + } + if let Some(look) = inherited { + s.doc_mut().appearances.bodies.insert(name.to_string(), look); + } + let id = s.doc_mut().add_feature(FeatureKind::BodyTransfer { bodies: vec![name.to_string()], from, to, transform }, None)?; + if let Some(feature) = s.doc_mut().feature_mut(id) { + feature.component = to; + } + Ok(id) +} + +fn finish(s: &mut Session, scratch: &mut Session, features: &[u64], cmd: &str) -> Result<()> { + scratch.refresh(); + for id in features { + if let Some(error) = scratch.model.result(*id).and_then(|result| result.error.as_ref()) { + return Err(bad(cmd, error)); + } + } + s.accept_computed(std::mem::take(scratch)); + Ok(()) +} + +pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result { + let cmd = "component.from_bodies"; + let bodies = body_names(s, p, cmd, false)?; + let mut scratch = s.scratch(); + let parent = s.active_component; + let mut made = Vec::new(); + let mut targets = Vec::new(); + for name in &bodies { + let component = scratch.doc_mut().add_component(Some(name), parent)?; + targets.push(component); + } + let frames = unique_frames(&scratch, cmd)?; + let mut features = Vec::new(); + for (name, component) in bodies.into_iter().zip(targets) { + let feature = transfer(&mut scratch, &name, component, &frames, cmd)?; + features.push(feature); + made.push(json!({"component":component,"body":name,"feature":feature})); + } + finish(s, &mut scratch, &features, cmd)?; + Ok(json!({"components":made})) +} + +pub(super) fn move_bodies(s: &mut Session, p: &Value) -> Result { + move_at_boundary(s, p, false) +} + +/// A solid constructor can attach its result immediately after the new feature, +/// including inside the scratch timeline used by Edit Feature. +pub(super) fn move_result(s: &mut Session, p: &Value) -> Result { + move_at_boundary(s, p, true) +} + +fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result { + let cmd = "component.move_bodies"; + let to = super::component(s, p, "component", cmd)?; + let bodies = body_names(s, p, cmd, allow_boundary)?; + let mut scratch = s.scratch(); + let frames = unique_frames(&scratch, cmd)?; + let mut features = Vec::new(); + for name in &bodies { + features.push(transfer(&mut scratch, name, to, &frames, cmd)?); + } + finish(s, &mut scratch, &features, cmd)?; + Ok(json!({"bodies":bodies,"component":to,"features":features})) +} + +#[cfg(test)] +#[path = "component_transfer_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests; diff --git a/crates/engine/src/cmd/component_transfer_tests.rs b/crates/engine/src/cmd/component_transfer_tests.rs new file mode 100644 index 00000000..9a6fd2e8 --- /dev/null +++ b/crates/engine/src/cmd/component_transfer_tests.rs @@ -0,0 +1,287 @@ +use super::*; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap() +} + +#[test] +fn recorded_assignment_preserves_sample_joined_material_and_timeline() { + let mut s = Session::default(); + s.run_script(&crate::sample::script()).unwrap(); + let before = s.model.state(); + assert_eq!(before.bodies.len(), 1); + let volume = solvecraft_kernel::measure(&before.body("Plate").unwrap().body).unwrap().volume; + let created = run(&mut s, "component.from_bodies", json!({"bodies":["Plate"]})); + let to = created["components"][0]["component"].as_u64().unwrap(); + let feature = created["components"][0]["feature"].as_u64().unwrap(); + let after = s.model.state(); + assert_eq!(after.bodies.len(), 1); + let body = after.body("Plate").unwrap(); + assert_eq!(body.component, to); + assert!(body.transferred); + assert!((solvecraft_kernel::measure(&body.body).unwrap().volume - volume).abs() < 1e-6); + assert!(body.mesh().contains(Vec3::new(40.0, 35.0, 12.0))); + let index = s.doc.feature_index(feature).unwrap(); + s.doc_mut().marker = Some(index); + s.refresh(); + assert_eq!(s.world_state().body("Plate").unwrap().component, 0); + s.doc_mut().marker = None; + s.refresh(); + assert_eq!(s.world_state().body("Plate").unwrap().component, to); +} + +#[test] +fn transformed_pending_destination_repeated_transfer_preserves_world_pose() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + let created = run(&mut s, "component.create", json!({"name":"Destination"})); + let to = created["component"].as_u64().unwrap(); + let occurrence = created["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false})); + run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Block"]})); + let state = s.world_state(); + let bounds = state.body("Block").unwrap().mesh().bounds(); + assert!(bounds.min.dist(Vec3::ZERO) < 1e-6); + assert!(bounds.max.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-6); + assert!(!s.pending_moves.contains_key(&occurrence)); + let mut destination_reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + destination_reopened.refresh(); + assert!(destination_reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6); +} + +#[test] +fn shared_destination_refuses_without_mutating_document_or_session() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + let c = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap(); + run(&mut s, "occurrence.copy", json!({"component":c,"translate":[20,0,0]})); + let document = s.doc.to_json(); + let active = s.active_component; + assert!(s.execute("component.move_bodies", &json!({"component":c,"bodies":["Block"]})).is_err()); + assert_eq!(s.doc.to_json(), document); + assert_eq!(s.active_component, active); +} + +#[test] +fn historical_join_cut_and_new_destination_modeling_keep_material() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"})); + run(&mut s, "solid.box", json!({"corner":[2,2,0],"length":2,"width":2,"height":10,"operation":"cut"})); + let original = s.model.state(); + assert_eq!(original.bodies.len(), 1); + let v = solvecraft_kernel::measure(&original.body("Block").unwrap().body).unwrap().volume; + assert!((v - 1760.0).abs() < 1e-6); + run(&mut s, "material.assign", json!({"bodies":["Block"],"material":"Brass"})); + run(&mut s, "appearance.assign", json!({"bodies":["Block"],"color":"#bc8537"})); + let created = run(&mut s, "component.from_bodies", json!({"bodies":["Block"]})); + let c = created["components"][0]["component"].as_u64().unwrap(); + assert_eq!(s.doc.materials["Block"], "Brass"); + assert_eq!(s.doc.appearances.bodies["Block"].color, [188, 133, 55]); + run(&mut s, "component.activate", json!({"component":c})); + run(&mut s, "solid.box", json!({"corner":[18,0,0],"length":2,"width":10,"height":10,"operation":"join"})); + assert_eq!(s.model.state().bodies.len(), 1); + assert_eq!(s.model.state().body("Block").unwrap().component, c); + let final_volume = solvecraft_kernel::measure(&s.model.state().body("Block").unwrap().body).unwrap().volume; + assert!((final_volume - 1960.0).abs() < 1e-6); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + assert_eq!(s.model.state().body("Block").unwrap().component, 0); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.model.state().body("Block").unwrap().component, c); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!((solvecraft_kernel::measure(&reopened.model.state().body("Block").unwrap().body).unwrap().volume - final_volume).abs() < 1e-6); +} + +#[test] +fn ownership_only_boundary_invalidates_old_boolean_cache() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"})); + assert_eq!(s.model.state().bodies.len(), 1); + let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap(); + let id = s + .doc_mut() + .add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: 0, to: c, transform: solvecraft_doc::IDENTITY }, None) + .unwrap(); + let index = s.doc.feature_index(id).unwrap(); + let transfer = s.doc_mut().features.remove(index); + s.doc_mut().features.insert(1, transfer); + s.refresh(); + assert_eq!(s.model.state().bodies.len(), 2); + assert_eq!(s.model.state().body("Block").unwrap().component, c); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert_eq!(reopened.model.state().bodies.len(), 2); + assert_eq!(reopened.model.state().body("Block").unwrap().component, c); +} + +#[test] +fn failed_transfer_cannot_change_evaluated_world_owner() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap(); + let id = s + .doc_mut() + .add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: c, to: c, transform: solvecraft_doc::IDENTITY }, None) + .unwrap(); + s.refresh(); + assert!(s.model.result(id).unwrap().error.is_some()); + assert_eq!(s.world_state().body("Block").unwrap().component, 0); +} + +#[test] +fn transferred_thread_follows_body_frame_without_changing_world_axis() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":4,"height":20,"body_name":"Shaft"})); + run(&mut s, "solid.thread", json!({"face":[4,0,10],"designation":"M8"})); + let before = s.world_state().threads[0].clone(); + let c = run(&mut s, "component.create", json!({"name":"Threaded"})); + let to = c["component"].as_u64().unwrap(); + let occurrence = c["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":90,"capture":true})); + run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Shaft"]})); + let after = s.world_state().threads[0].clone(); + assert!(after.axis_point.dist(before.axis_point) < 1e-6); + assert!(after.axis.dist(before.axis) < 1e-6); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.world_state().threads[0].axis.dist(before.axis) < 1e-6); +} + +#[test] +fn inherited_component_appearance_and_legacy_assignment_survive_transfer() { + let mut s = Session::default(); + let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + s.doc_mut().appearances.components.insert(source, solvecraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 }); + s.refresh(); + let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap(); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + assert_eq!(s.doc.model_body_look(s.model.state().body("Block").unwrap()), Some(look)); + // An unrecorded legacy assignment retains its established replay policy. + let mut legacy = Session::default(); + run(&mut legacy, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Old"})); + let c = legacy.doc_mut().add_component(Some("Legacy"), 0).unwrap(); + legacy.doc_mut().body_components.insert("Old".into(), c); + legacy.doc_mut().body_offsets.insert("Old".into(), solvecraft_doc::rigid(Vec3::new(7.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0)); + legacy.refresh(); + assert!(!legacy.model.state().body("Old").unwrap().transferred); + run(&mut legacy, "component.move_bodies", json!({"component":0,"bodies":["Old"]})); + assert!((legacy.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!((reopened.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6); +} + +#[test] +fn deleting_external_transfer_source_refuses_without_dangling_archive() { + let mut s = Session::default(); + let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); + run(&mut s, "component.activate", json!({"component":0})); + let b = run(&mut s, "component.create", json!({"name":"Destination"}))["component"].as_u64().unwrap(); + run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Block"]})); + let document = s.doc.to_json(); + assert!(s.execute("component.delete", &json!({"component":a})).is_err()); + assert_eq!(s.doc.to_json(), document); + assert!(solvecraft_doc::Document::from_json(&document).is_ok()); +} + +#[test] +fn earlier_selected_cylinder_emboss_keeps_source_frame_after_transfer_and_edit() { + let mut s = Session::default(); + let source = run(&mut s, "component.create", json!({"name":"Source"})); + let occurrence = source["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":true})); + run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[30,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + let emboss = run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[30,10,7.5],"depth":1}))["feature"].as_u64().unwrap(); + let before = solvecraft_kernel::measure(&s.world_state().body("Sleeve").unwrap().body).unwrap().volume; + run(&mut s, "component.activate", json!({"component":0})); + let destination = run(&mut s, "component.create", json!({"name":"Destination"})); + let to = destination["component"].as_u64().unwrap(); + let destination_occurrence = destination["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":destination_occurrence,"translate":[0,40,0],"axis":[1,0,0],"angle":90,"capture":true})); + run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Sleeve"]})); + assert!(s.doc.features.iter().all(|feature| s.model.result(feature.id).is_some_and(|result| result.error.is_none()))); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none()))); + assert!((solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume - before).abs() < 1e-5); + let depth = reopened.doc.feature(emboss).unwrap().param_names[0].clone(); + run(&mut reopened, "parameters.change", json!({"name":depth,"expression":"2 mm"})); + assert!(solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume > before); +} + +#[test] +fn transfer_undo_redo_restores_original_pending_source_world_pose() { + let mut s = Session::default(); + let created = run(&mut s, "component.create", json!({"name":"Source"})); + let source = created["component"].as_u64().unwrap(); + let occurrence = created["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false})); + let pending = s.pending_moves.clone(); + let before = s.world_state().body("Block").unwrap().mesh().bounds(); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.world_state().body("Block").unwrap().component, source); + assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6); + run(&mut s, "edit.redo", json!({})); + assert!(s.pending_moves.is_empty()); + assert_eq!(s.world_state().body("Block").unwrap().component, 0); + assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6); +} + +#[test] +fn transfer_checkpoint_restores_pending_world_pose_and_history() { + let mut s = Session::default(); + let created = run(&mut s, "component.create", json!({"name":"Source"})); + let source = created["component"].as_u64().unwrap(); + let occurrence = created["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false})); + let pending = s.pending_moves.clone(); + let before = s.world_state().body("Block").unwrap().mesh().bounds(); + let undo_count = s.undo.len(); + let checkpoint = s.checkpoint(); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + s.restore_checkpoint(checkpoint).unwrap(); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.undo.len(), undo_count); + assert_eq!(s.world_state().body("Block").unwrap().component, source); + assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6); +} + +#[test] +fn edited_transfer_destination_updates_history_owner_copy_and_reopen() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); + let a = run(&mut s, "component.create", json!({"name":"A","parent":0}))["component"].as_u64().unwrap(); + let b = run(&mut s, "component.create", json!({"name":"B","parent":0}))["component"].as_u64().unwrap(); + let transferred = run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Block"]})); + let feature = transferred["features"][0].as_u64().unwrap(); + run(&mut s, "timeline.edit", json!({"feature":feature,"set":{"to":b,"component":a}})); + assert_eq!(s.doc.feature(feature).unwrap().component, b); + assert_eq!(s.model.state().body("Block").unwrap().component, b); + let saved = s.doc.to_json(); + let pending = s.pending_moves.clone(); + assert!(s.execute("timeline.edit", &json!({"feature":feature,"set":{"to":u64::MAX}})).is_err()); + assert_eq!(s.doc.to_json(), saved); + assert_eq!(s.pending_moves, pending); + let copied = run(&mut s, "component.paste_new", json!({"component":b}))["component"].as_u64().unwrap(); + assert_eq!(s.model.state().body("Block (copy)").unwrap().component, copied); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert_eq!(reopened.model.state().body("Block").unwrap().component, b); + assert_eq!(reopened.model.state().body("Block (copy)").unwrap().component, copied); +} diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index 74f2c8dc..d5335c98 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -10,21 +10,90 @@ 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"), + CommandSpec::new("config.preview", "Preview Configuration", preview).noundo().params("row: name — validates a rebuild without changing the design or history"), + CommandSpec::new("config.rule", "Configuration Rule", rule).params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"), + CommandSpec::new("config.rules", "Configuration Rules", show).noundo().params("ordered typed rules, rows and active state"), ]; fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { + if ["param", "feature", "material", "appearance", "component_appearance", "face_appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 + { + return Err(bad(cmd, "give exactly one configuration target")); + } + if let Some(value) = p.get("component_appearance") { + let key = value + .as_str() + .map(str::to_string) + .or_else(|| value.as_u64().map(|id| id.to_string())) + .ok_or_else(|| bad(cmd, "component name or ID required"))?; + if bool_(p, "delete") == Some(true) + && let Some(column) = s.doc.configs.columns.iter().find(|column| matches!(column,Column::ComponentAppearance(id) if id.to_string()==key)) + { + return Ok(column.clone()); + } + let id = s + .doc + .find_component(&key) + .or_else(|| s.doc.occurrences.iter().find(|occurrence| occurrence.name == key).map(|occurrence| occurrence.component)) + .filter(|id| *id != 0) + .ok_or_else(|| bad(cmd, "no component definition"))?; + return Ok(Column::ComponentAppearance(id)); + } + if let Some(value) = p.get("face_appearance") { + if bool_(p, "delete") == Some(true) { + let key = value.as_str().ok_or_else(|| bad(cmd, "face column key required for deletion"))?; + return s + .doc + .configs + .columns + .iter() + .find(|column| matches!(column, Column::FaceAppearance(_)) && s.doc.config_key(column) == key) + .cloned() + .ok_or_else(|| bad(cmd, "no face column")); + } + if let Some(target) = value.get("target") { + solvecraft_doc::config::validate_face_target_json(target).map_err(|error| bad(cmd, error.to_string()))?; + let target: solvecraft_doc::config::ConfigFaceTarget = + serde_json::from_value(target.clone()).map_err(|error| bad(cmd, error.to_string()))?; + let state = s.model.state(); + let body = state.body(&target.body).ok_or_else(|| bad(cmd, "no face definition"))?; + s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?; + return Ok(Column::FaceAppearance(target)); + } + let body_name = value.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "face body required"))?; + let point = value.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "finite face point required"))?; + let world = s.world_state(); + let body = world.body(body_name).ok_or_else(|| bad(cmd, "no face body"))?; + if body.body.is_mesh() { + return Err(bad(cmd, "mesh faces have no supported semantic lineage")); + } + let index = solvecraft_doc::appearance::face_index_at(body, point).ok_or_else(|| bad(cmd, "no face at picked point"))?; + let target = s.doc.configuration_face_target(body, index, point).map_err(|error| bad(cmd, error.to_string()))?; + s.doc.certify_config_face(&target).map_err(|error| bad(cmd, error.to_string()))?; + s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?; + return Ok(Column::FaceAppearance(target)); + } + 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}`"))); @@ -38,7 +107,9 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { /// Add a column, filling existing rows with the design's current value. fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> { - if s.doc.configs.columns.contains(&c) { + if s.doc.configs.columns.iter().any(|existing| { + *existing == c || matches!((existing,&c),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name) + }) { return Ok(()); } if s.doc.configs.columns.len() >= MAX_COLUMNS { @@ -63,6 +134,25 @@ 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)?; + } + } + for (plural, singular) in [("component_appearances", "component_appearance"), ("face_appearances", "face_appearance")] { + if let Some(values) = p.get(plural) { + let values = values + .as_array() + .filter(|values| values.len() <= MAX_COLUMNS) + .ok_or_else(|| bad(cmd, "configuration targets must be a bounded array"))?; + for value in values { + let c = column_of(s, &json!({singular:value}), 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; @@ -80,7 +170,26 @@ fn show(s: &mut Session, _p: &Value) -> Result { .iter() .map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::>()})) .collect(); - Ok(json!({"columns": keys, "rows": rows, "active": t.active, "matches": s.doc.configuration_matches()})) + let targets: Vec = t + .columns + .iter() + .map(|column| { + let label = match column { + Column::ComponentAppearance(id) => { + format!("Component: {}", s.doc.components.iter().find(|c| c.id == *id).map(|c| c.name.clone()).unwrap_or_else(|| id.to_string())) + } + Column::FaceAppearance(target) => { + format!("Face: {} / {}", target.body, target.name.split_once(':').map(|(_, role)| role).unwrap_or(&target.name)) + } + _ => s.doc.config_key(column), + }; + json!({"key":s.doc.config_key(column),"label":label,"target":column}) + }) + .collect(); + let styles = s.doc.configuration_style_status(&s.model.state()); + Ok( + json!({"columns": keys, "targets":targets, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches() && styles.is_ok(), "style_status":styles.as_ref().ok(),"style_error":styles.err().map(|error|error.to_string())}), + ) } fn column(s: &mut Session, p: &Value) -> Result { @@ -98,6 +207,9 @@ fn column(s: &mut Session, p: &Value) -> Result { } else { add_column(s, c, cmd)?; } + if !s.doc.configs.rules.is_empty() { + validate_rows(s, cmd)?; + } show(s, p) } @@ -107,6 +219,9 @@ fn row(s: &mut Session, p: &Value) -> Result { let idx = s.doc.configs.rows.iter().position(|r| r.name == name); if bool_(p, "delete").unwrap_or(false) { let i = idx.ok_or_else(|| bad(cmd, format!("no row `{name}`")))?; + if s.doc.configs.rows.len() == 1 && !s.doc.configs.rules.is_empty() { + return Err(bad(cmd, "delete rules before deleting the last configuration row")); + } let t = &mut s.doc_mut().configs; t.rows.remove(i); if t.active.as_deref() == Some(name.as_str()) { @@ -122,33 +237,58 @@ 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:") || k.starts_with("component_appearance:") || k.starts_with("face_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(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_) => 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(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => { + 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; + let validated_name = rename.clone().unwrap_or_else(|| name.clone()); match idx { Some(i) => { if let Some(r) = t.rows.get_mut(i) { @@ -168,20 +308,118 @@ fn row(s: &mut Session, p: &Value) -> Result { t.rows.push(ConfigRow { name, cells }); } } + if !s.doc.configs.rules.is_empty() { + let mut trial = s.scratch(); + activate(&mut trial, &json!({"row":validated_name})).map_err(|e| bad(cmd, e.to_string()))?; + } + show(s, p) +} + +fn validate_rows(s: &Session, cmd: &str) -> Result<()> { + if !s.doc.configs.rules.is_empty() && s.doc.configs.rows.is_empty() { + return Err(bad(cmd, "rules require at least one configuration row")); + } + for row in &s.doc.configs.rows { + let mut trial = s.scratch(); + activate(&mut trial, &json!({"row":row.name})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?; + } + Ok(()) +} + +fn rule(s: &mut Session, p: &Value) -> Result { + let cmd = "config.rule"; + if let Some(value) = p.get("rule") { + solvecraft_doc::config::validate_rules_json(&json!({"configs":{"rules":[value]}})).map_err(|e| bad(cmd, e.to_string()))?; + let mut rule: solvecraft_doc::config::ConfigRule = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, e.to_string()))?; + if rule.length_unit.is_none() { + rule.length_unit = s + .doc + .configs + .rules + .iter() + .find(|existing| existing.id == rule.id) + .and_then(|existing| existing.length_unit.clone()) + .or_else(|| Some(s.doc.units.clone())); + } + let rules = &mut s.doc_mut().configs.rules; + if let Some(index) = rules.iter().position(|r| r.id == rule.id) { + rules[index] = rule; + } else { + if rules.len() >= solvecraft_doc::config::MAX_RULES { + return Err(bad(cmd, "too many rules")); + } + rules.push(rule); + } + } else { + let id = str_(p, "id").ok_or_else(|| bad(cmd, "give rule or id"))?; + let rules = &mut s.doc_mut().configs.rules; + let index = rules.iter().position(|r| r.id == id).ok_or_else(|| bad(cmd, "unknown rule"))?; + if bool_(p, "delete").unwrap_or(false) { + rules.remove(index); + } else { + let position = p + .get("index") + .and_then(Value::as_u64) + .and_then(|v| usize::try_from(v).ok()) + .filter(|v| *v < rules.len()) + .ok_or_else(|| bad(cmd, "valid index required"))?; + let rule = rules.remove(index); + rules.insert(position, rule); + } + } + validate_rows(s, cmd)?; show(s, p) } fn activate(s: &mut Session, p: &Value) -> Result { let cmd = "config.activate"; let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string(); - s.doc_mut().apply_configuration(&r).map_err(|e| bad(cmd, e.to_string()))?; + let mut trial = s.scratch(); + let trace = trial.doc_mut().apply_configuration_with_trace(&r).map_err(|e| bad(cmd, e.to_string()))?; + trial.refresh(); + let new_errors: Vec = trial + .model + .results + .iter() + .filter_map(|result| { + result + .error + .as_ref() + .filter(|error| s.model.result(result.id).is_none_or(|before| before.error.as_ref() != Some(*error))) + .map(|error| format!("{}: {error}", result.name)) + }) + .collect(); + if !new_errors.is_empty() { + return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; ")))); + } + let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?; + s.doc = trial.doc; s.refresh(); let errors: Vec = s.model.results.iter().filter_map(|x| x.error.clone()).collect(); let mut out = show(s, p)?; out["errors"] = json!(errors); + out["trace"] = json!(trace); + out["styles"] = json!(styles); Ok(out) } +fn preview(s: &mut Session, p: &Value) -> Result { + let mut trial = s.scratch(); + let mut out = activate(&mut trial, p)?; + out["preview"] = json!(true); + out["parameters"] = json!(trial.doc.param_rows()); + Ok(out) +} + +#[cfg(test)] +#[path = "config_certificate_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod certificate_tests; +#[cfg(test)] +#[path = "config_style_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod style_tests; + #[cfg(test)] mod tests { use serde_json::{Value, json}; @@ -199,6 +437,186 @@ mod tests { run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) } + #[test] + fn configuration_activation_tolerates_identical_existing_errors_but_rejects_reenabled_failure() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name":"radius","expression":"10 mm"})); + run(&mut s, "solid.sphere", json!({"radius":"radius"})); + let feature = s.doc.features[0].name.clone(); + run(&mut s, "config.configure", json!({"params":["radius"],"features":[feature]})); + run(&mut s, "config.row", json!({"name":"Existing error","values":{"radius":"-1 mm"}})); + run(&mut s, "parameters.change", json!({"name":"radius","expression":"-1 mm"})); + assert!(s.model.results[0].error.is_some()); + let existing = run(&mut s, "config.activate", json!({"row":"Existing error"})); + assert_eq!(existing["errors"].as_array().unwrap().len(), 1); + s.doc_mut().features[0].suppressed = true; + s.refresh(); + assert!(s.model.results[0].error.is_none()); + run(&mut s, "config.row", json!({"name":"Reenabled failure","values":{"radius":"-1 mm",format!("suppress:{feature}"):false}})); + let before = s.doc.clone(); + assert!(s.execute("config.activate", &json!({"row":"Reenabled failure"})).is_err()); + assert_eq!(s.doc, before); + assert!(s.model.results[0].error.is_none()); + } + + #[test] + fn configuration_rows_and_literal_rule_thresholds_keep_physical_units_on_switch() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); + let length = s.doc.features[0].param_names[0].clone(); + run(&mut s, "config.configure", json!({"params":[length]})); + run( + &mut s, + "config.rule", + json!({"rule":{"id":"limit","name":"Metric stock","condition":{"type":"parameter","parameter":length,"operator":"le","expression":"20"},"effect":{"type":"parameter_range","parameter":length,"minimum":"5","maximum":"20"}}}), + ); + run(&mut s, "document.units", json!({"units":"in"})); + assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm"); + let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap(); + assert_eq!(rule["condition"]["expression"], "20 mm"); + assert_eq!(rule["effect"]["minimum"], "5 mm"); + let preview = run(&mut s, "config.preview", json!({"row":"Default"})); + assert_eq!(preview["trace"][0]["matched"], true); + run(&mut s, "config.activate", json!({"row":"Default"})); + assert!((volume(&mut s) - 1000.0).abs() < 1e-6); + assert!(s.doc.configuration_matches()); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.units, "mm"); + assert_eq!(s.doc.configs.rows[0].cells[0], "10"); + } + + #[test] + fn configuration_mixed_rule_expressions_keep_authoring_units_without_rewriting_source() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":20,"height":10})); + let length = s.doc.features[0].param_names[0].clone(); + let width = s.doc.features[0].param_names[1].clone(); + run(&mut s, "config.configure", json!({"params":[length,width]})); + let expression = format!("{width} + 1"); + run( + &mut s, + "config.rule", + json!({"rule":{"id":"drive","name":"Stock extra","condition":{"type":"always"},"effect":{"type":"drive_parameter","parameter":length,"expression":expression}}}), + ); + run( + &mut s, + "config.rule", + json!({"rule":{"id":"range","name":"Stock bound","condition":{"type":"parameter","parameter":length,"operator":"lt","expression":format!("{width} + 2")},"effect":{"type":"parameter_range","parameter":length,"minimum":width,"maximum":format!("{width} + 2")}}}), + ); + run(&mut s, "config.activate", json!({"row":"Default"})); + let before = volume(&mut s); + assert!((before - 4200.0).abs() < 1e-6); + run(&mut s, "document.units", json!({"units":"in"})); + assert!((volume(&mut s) - before).abs() < 1e-6, "matched row rederives drives in their authoring unit"); + assert!(s.doc.configuration_matches()); + let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap(); + assert_eq!(rule["effect"]["expression"], expression); + assert_eq!(rule["length_unit"], "mm"); + let preview = run(&mut s, "config.preview", json!({"row":"Default"})); + assert_eq!(preview["trace"][1]["matched"], true); + } + + #[test] + fn configuration_rule_edits_enforce_existing_rows_and_are_undoable() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name":"length","expression":"10 mm"})); + run(&mut s, "solid.box", json!({"length":"length","width":10,"height":10})); + run(&mut s, "config.configure", json!({"params":["length"]})); + let rule = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"5 mm","maximum":"20 mm"}}); + run(&mut s, "config.rule", json!({"rule":rule})); + let before = s.doc.clone(); + assert!(s.execute("config.row", &json!({"name":"Default","delete":true})).is_err()); + assert_eq!(s.doc, before, "last row remains while rules exist"); + assert!(s.execute("config.row", &json!({"name":"Too large","values":{"length":"30 mm"}})).is_err()); + assert_eq!(s.doc, before); + let impossible = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"15 mm","maximum":"20 mm"}}); + assert!(s.execute("config.rule", &json!({"rule":impossible})).is_err()); + assert_eq!(s.doc, before); + let preview = run(&mut s, "config.preview", json!({"row":"Default"})); + assert_eq!(preview["trace"][0]["name"], "Length limit"); + assert_eq!(preview["trace"][0]["matched"], true); + assert_eq!(s.doc, before); + run(&mut s, "config.rule", json!({"id":"limit","delete":true})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before); + let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(reopened.configs.rules, s.doc.configs.rules); + } + + #[test] + fn configuration_preview_and_failed_geometry_leave_design_unchanged() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name":"length", "expression":"10 mm"})); + run(&mut s, "solid.box", json!({"length":"length", "width":10, "height":10})); + run(&mut s, "config.configure", json!({"params":["length"]})); + run(&mut s, "config.row", json!({"name":"Large", "values":{"length":"20 mm"}})); + run(&mut s, "config.row", json!({"name":"Degenerate", "values":{"length":"0 mm"}})); + let before = s.doc.clone(); + let revision = s.revision; + assert_eq!(run(&mut s, "config.preview", json!({"row":"Large"}))["preview"], true); + assert_eq!(s.doc, before); + assert_eq!(s.revision, revision); + assert!(s.execute("config.preview", &json!({"row":"Degenerate"})).is_err()); + assert_eq!(s.doc, before); + assert!(s.execute("config.activate", &json!({"row":"Degenerate"})).is_err()); + assert_eq!(s.doc, before); + assert!((volume(&mut s) - 1000.0).abs() < 1e-6); + run(&mut s, "config.activate", json!({"row":"Large"})); + assert!((volume(&mut s) - 2000.0).abs() < 1e-6); + } + + #[test] + fn configured_feature_inputs_and_suppression_match_typed_cells() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10, "width":10, "height":10})); + let feature = s.doc.features[0].name.clone(); + let length = s.doc.features[0].param_names[0].clone(); + s.doc_mut().units = "in".into(); + run(&mut s, "config.configure", json!({"params":[length], "features":[feature]})); + assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm", "Default captures stored feature literals in their base unit"); + run(&mut s, "config.activate", json!({"row":"Default"})); + assert!((volume(&mut s) - 1000.0).abs() < 1e-6, "captured Default preserves geometry in an inch design"); + run(&mut s, "config.row", json!({"name":"Inches", "values":{length.clone():"2", format!("suppress:{feature}"):"NO"}})); + run(&mut s, "config.activate", json!({"row":"Inches"})); + assert_eq!(s.doc.config_current(&solvecraft_doc::config::Column::Param(length.clone())).as_deref(), Some("2 in")); + assert!(s.doc.configuration_matches()); + assert!((volume(&mut s) - 5080.0).abs() < 1e-6); + let feature_id = s.doc.features[0].id; + run( + &mut s, + "config.rule", + json!({"rule":{"id":"inch-condition","name":"Inch condition","condition":{"type":"parameter","parameter":length,"operator":"gt","expression":"5"},"effect":{"type":"drive_suppression","feature":feature_id,"value":true}}}), + ); + run( + &mut s, + "config.rule", + json!({"rule":{"id":"inch-range","name":"Inch range","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":length,"minimum":"0","maximum":"5"}}}), + ); + let preview = run(&mut s, "config.preview", json!({"row":"Inches"})); + assert_eq!(preview["trace"][0]["matched"], false, "bare comparison means inches for a feature length"); + assert_eq!(preview["trace"][1]["matched"], true); + } + + #[test] + fn configured_parameters_cannot_be_deleted_and_deleted_features_prune_columns() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name":"spare", "expression":"10 mm"})); + run(&mut s, "config.configure", json!({"params":["spare"]})); + assert!(s.doc_mut().remove_param("spare").is_err()); + run(&mut s, "config.column", json!({"param":"spare", "delete":true})); + s.doc_mut().remove_param("spare").unwrap(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); + let feature = &s.doc.features[0]; + let id = feature.id; + let length = feature.param_names[0].clone(); + let name = feature.name.clone(); + run(&mut s, "config.configure", json!({"params":[length], "features":[name]})); + s.doc_mut().delete_feature(id).unwrap(); + assert!(s.doc.configs.columns.is_empty()); + assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty())); + } + #[test] fn configurations_switch_sizes_and_suppressions() { let mut s = Session::default(); @@ -236,4 +654,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/config_certificate_tests.rs b/crates/engine/src/cmd/config_certificate_tests.rs new file mode 100644 index 00000000..da39d20a --- /dev/null +++ b/crates/engine/src/cmd/config_certificate_tests.rs @@ -0,0 +1,251 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_doc::{ + FeatureKind, + config::{Column, FaceCertificate}, +}; +use solvecraft_geom::Vec3; + +fn run(session: &mut Session, command: &str, params: Value) -> Value { + session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}")) +} +fn paint(session: &mut Session, body: &str, point: Value) { + let table = run(session, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]})); + let key = table["columns"][0].as_str().unwrap(); + run(session, "config.row", json!({"name":"Painted","values":{key:"ABS - Red"}})); + run(session, "config.activate", json!({"row":"Painted"})); +} +fn painted_radius(session: &Session, name: &str, radius: f64) -> usize { + let state = session.model.state(); + let body = + state.body(name).unwrap_or_else(|| panic!("missing {name}; bodies {:?}", state.bodies.iter().map(|body| &body.name).collect::>())); + let painted = session.doc.face_colors(body); + let mesh = body.body.tessellate(0.001).unwrap(); + let inverse = solvecraft_doc::mat_inverse(&body.source_transform).unwrap(); + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if painted.iter().any(|(index, _)| *index == *face as usize) { + for vertex in mesh.tri(triangle).unwrap() { + let vertex = solvecraft_doc::apply_point(&inverse, vertex); + assert!((vertex.x.hypot(vertex.y) - radius).abs() < 1e-7, "{name}: {vertex:?}"); + } + } + } + assert!(!painted.is_empty()); + painted.len() +} + +#[test] +fn certified_annulus_side_rows_follow_radius_edits_and_clone() { + let mut s = Session::default(); + let part = run(&mut s, "component.create", json!({"name":"RingPart"}))["component"].as_u64().unwrap(); + let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"})); + run(&mut s, "sketch.dimension", json!({"entities":["outer"],"type":"radius","value":10})); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"})); + run(&mut s, "sketch.dimension", json!({"entities":["inner"],"type":"radius","value":9})); + let FeatureKind::Sketch { sketch: source_sketch, .. } = &s.doc.feature(sketch).unwrap().kind else { panic!() }; + let dimensions: Vec<_> = source_sketch.constraints.iter().filter_map(|dimension| dimension.param.clone()).collect(); + assert_eq!(dimensions.len(), 2); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10,"profiles":[{"point":[9.5,0]}],"body_name":"Ring"})); + paint(&mut s, "Ring", json!([10, 0, 5])); + assert_eq!(painted_radius(&s, "Ring", 10.), 2, "both constructed arc pieces share outer curve"); + let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() }; + assert_eq!(target.certificate, Some(FaceCertificate::ExtrudeSide { sketch, curve: "outer".into() })); + run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"12 mm"})); + run(&mut s, "parameters.change", json!({"name":dimensions[1],"expression":"9.25 mm"})); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Ring", 12.), 2); + run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]})); + assert_eq!(s.doc.configs.columns.len(), 2); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Ring (copy)", 12.), 2); + let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let mut reopened = Session::new(reopened); + run(&mut reopened, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&reopened, "Ring", 12.), 2); + assert_eq!(painted_radius(&reopened, "Ring (copy)", 12.), 2); + run(&mut reopened, "config.activate", json!({"row":"Default"})); + assert!(reopened.doc.appearances.faces.is_empty()); +} + +fn imported(session: &mut Session, step: String, name: &str) -> u64 { + let owner = session.active_component; + let id = session.doc_mut().add_feature(FeatureKind::Import { file: "fixture.step".into(), step, components: vec![] }, Some(name)).unwrap(); + let feature = session.doc_mut().feature_mut(id).unwrap(); + feature.body_names = vec![name.into()]; + feature.component = owner; + session.refresh(); + assert!(session.model.result(id).unwrap().error.is_none()); + id +} + +#[test] +fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renaming() { + let mut s = Session::default(); + let cylinder = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + let step = solvecraft_kernel::step_export(&[&cylinder], "legacy anchored cylinder").unwrap(); + let source = imported(&mut s, step, "Sleeve"); + run(&mut s, "material.assign", json!({"bodies":["Sleeve"],"material":"Steel"})); + let before_volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + let legacy_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap()); + assert!(legacy_names.iter().all(|name| name.starts_with(&format!("F{source}:face:")))); + run(&mut s, "appearance.assign", json!({"faces":[{"body":"Sleeve","point":[6,0,20]}],"appearance":"ABS - Blue"})); + assert!(s.doc.appearances.faces[0].certificate.is_none()); + let badge = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,20]},"name":"Badge","project_edges":false}))["sketch"].as_u64().unwrap(); + let FeatureKind::Sketch { plane: saved_plane, .. } = &s.doc.feature(badge).unwrap().kind else { panic!() }; + let saved_plane = saved_plane.clone(); + assert!(matches!(&saved_plane, solvecraft_doc::PlaneRef::Face { name: Some(name), .. } if legacy_names.contains(name))); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,-2],"p1":[2,2]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":1,"targets":["Sleeve"],"name":"Raised Badge"})); + let anchored = s.doc.find_feature("Raised Badge").unwrap().clone(); + assert!(matches!(anchored.kind, FeatureKind::Emboss { face: None, .. })); + let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + assert!((volume - std::f64::consts::PI * 2000. - 16.).abs() < 0.1, "volume {volume}"); + assert!((volume - before_volume - 16.).abs() < 0.01, "addedvolume {} baseline {before_volume}", volume - before_volume); + let paint = s.doc.appearances.faces.clone(); + let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + legacy["format"] = json!("solvecraft/9"); + let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap()); + assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored); + let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() }; + assert_eq!(plane, &saved_plane); + assert_eq!(reopened.doc.appearances.faces, paint); + assert_eq!(reopened.doc.materials.get("Sleeve"), Some(&"Steel".to_string())); + assert!(reopened.model.result(anchored.id).unwrap().error.is_none()); + let state = reopened.model.state(); + let body = state.body("Sleeve").unwrap(); + assert!((body.body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8); + assert!(!reopened.doc.face_colors(body).is_empty()); +} + +#[test] +fn certified_side_refuses_reused_body_name_from_a_new_producer() { + let mut s = Session::default(); + let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"})); + run(&mut s, "sketch.finish", json!({})); + let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap(); + paint(&mut s, "Round", json!([10, 0, 5])); + let Column::FaceAppearance(target) = s.doc.configs.columns[0].clone() else { panic!() }; + let mut replacement = s.doc.feature(source).unwrap().clone(); + replacement.id = s.doc.next_id; + assert!(matches!(replacement.kind, FeatureKind::Extrude { sketch: original, .. } if original == sketch)); + s.doc_mut().feature_mut(source).unwrap().suppressed = true; + s.doc_mut().next_id += 1; + s.doc_mut().features.push(replacement); + s.refresh(); + let state = s.model.state(); + let body = state.body("Round").unwrap(); + assert!(s.doc.configuration_face_indices(body, &target).is_err()); + assert!(s.doc.face_colors(body).is_empty()); + let before = s.doc.clone(); + let history = s.undo.len(); + assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.undo.len(), history); +} + +#[test] +fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"})); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":20,"profiles":[{"point":[9.5,0]}],"body_name":"Sleeve"})); + let names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap()); + // Preserve the legacy centroid-family name even though strict paint uses + // the separately certified outer source curve. + let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0); + assert!(name.contains("side:inner")); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"],"name":"Raised Badge"})); + let feature = s.doc.find_feature("Raised Badge").unwrap(); + assert_eq!(feature.face_names, vec![name]); + assert!(feature.face_names.iter().all(|name| names.contains(name))); + let feature_id = feature.id; + let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + assert!((volume - std::f64::consts::PI * 380. - 21.).abs() < 0.2, "volume {volume}"); + let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + legacy["format"] = json!("solvecraft/9"); + let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap()); + assert!(reopened.model.result(feature_id).unwrap().error.is_none()); + assert!((reopened.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8); +} + +#[test] +fn certified_side_transfer_preserves_geometry_and_unsupported_taper_refuses_atomically() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10})); + run(&mut s, "sketch.finish", json!({})); + let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap(); + paint(&mut s, "Round", json!([10, 0, 5])); + let created = run(&mut s, "component.create", json!({"name":"Placed"})); + let destination = created["component"].as_u64().unwrap(); + let occurrence = created["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false})); + run(&mut s, "component.move_bodies", json!({"component":destination,"bodies":["Round"]})); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Round", 10.), 2); + let before = s.world_state().body("Round").unwrap().mesh().bounds(); + let mut s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Round", 10.), 2); + assert!( + s.world_state().body("Round").unwrap().mesh().bounds().min.dist(before.min) < 1e-8, + "before {before:?}, reopened {:?}", + s.world_state().body("Round").unwrap().mesh().bounds() + ); + // A successful ordinary modifier has no exact untapered source certificate. + let FeatureKind::Extrude { extent, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() }; + extent.taper = Some("5 deg".into()); + s.refresh(); + assert!(s.model.result(source).unwrap().error.is_none()); + assert!(s.doc.face_colors(s.model.state().body("Round").unwrap()).is_empty()); + let document = s.doc.clone(); + let history = s.undo.len(); + assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err()); + assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err()); + assert_eq!(s.doc, document); + assert_eq!(s.undo.len(), history); +} + +#[test] +fn certified_import_payload_binding_roundtrip_clone_and_replacement_refusal() { + let geometry = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 10.).unwrap(); + let step = solvecraft_kernel::step_export(&[&geometry], "certificate fixture").unwrap(); + let mut s = Session::default(); + let part = run(&mut s, "component.create", json!({"name":"ImportedPart"}))["component"].as_u64().unwrap(); + let source = imported(&mut s, step.clone(), "Imported"); + paint(&mut s, "Imported", json!([10, 0, 5])); + let pieces = painted_radius(&s, "Imported", 10.); + let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() }; + assert!( + matches!(&target.certificate, Some(FaceCertificate::Imported { item, face, payload_sha256 }) if *item != 0 && *face != 0 && payload_sha256.len() == 64) + ); + run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]})); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces); + let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let mut s = Session::new(reopened); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Imported", 10.), pieces); + // Identical geometry, entity IDs and item IDs, but a replaced embedded payload. + let FeatureKind::Import { step: payload, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() }; + *payload = step.replace("certificate fixture", "replacement fixture"); + assert_ne!(*payload, step); + s.refresh(); + assert!(s.doc.face_colors(s.model.state().body("Imported").unwrap()).is_empty()); + assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces); + let before = s.doc.clone(); + let history = s.undo.len(); + assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err()); + assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.undo.len(), history); +} diff --git a/crates/engine/src/cmd/config_style_tests.rs b/crates/engine/src/cmd/config_style_tests.rs new file mode 100644 index 00000000..d81c0591 --- /dev/null +++ b/crates/engine/src/cmd/config_style_tests.rs @@ -0,0 +1,232 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_doc::config::{Column, ConfigFaceTarget}; +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}")) +} +fn block(s: &mut Session) -> String { + run(s, "solid.box", json!({"length":10,"width":10,"height":10})); + s.model.state().bodies[0].name.clone() +} +fn face_column(s: &mut Session, body: &str, point: Value) -> String { + run(s, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}))["columns"][0].as_str().unwrap().into() +} + +#[test] +fn component_appearance_shared_definition_inheritance_and_delete() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap(); + let body = block(&mut s); + run(&mut s, "material.assign", json!({"bodies":[body],"material":"Steel"})); + run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]})); + let table = run(&mut s, "config.configure", json!({"component_appearances":[component]})); + let key = table["columns"][0].as_str().unwrap().to_string(); + run(&mut s, "config.row", json!({"name":"Red","values":{key.clone():"ABS - Red"}})); + run(&mut s, "config.activate", json!({"row":"Red"})); + let world = s.world_state(); + assert_eq!(world.bodies.len(), 2); + assert!(world.bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Red")); + run(&mut s, "appearance.assign", json!({"bodies":[body],"appearance":"ABS - Yellow"})); + assert!(s.world_state().bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Yellow")); + run(&mut s, "config.activate", json!({"row":"Default"})); + assert!(!s.doc.appearances.components.contains_key(&component)); + assert_eq!(s.doc.appearances.bodies[&body].name, "ABS - Yellow"); + run(&mut s, "component.rename", json!({"component":component,"name":"Renamed"})); + assert_eq!(run(&mut s, "config.table", json!({}))["columns"][0], key); + run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"appearance":"ABS - Blue"})); + run(&mut s, "component.delete", json!({"component":component})); + assert!(s.doc.configs.columns.is_empty()); + assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty())); + assert!(!s.doc.appearances.components.contains_key(&component)); +} + +#[test] +fn strict_face_rows_resize_rename_preview_suppress_and_roundtrip() { + let mut s = Session::default(); + let body = block(&mut s); + let key = face_column(&mut s, &body, json!([5, 5, 10])); + let feature = s.doc.features[0].name.clone(); + let height = s.doc.features[0].param_names[2].clone(); + run(&mut s, "config.column", json!({"param":height})); + run(&mut s, "config.column", json!({"feature":feature})); + run(&mut s, "config.row", json!({"name":"Tall","values":{key:"ABS - Red",height:"20 mm"}})); + let before = s.doc.clone(); + let revision = s.revision; + let history = s.undo.len(); + let preview = run(&mut s, "config.preview", json!({"row":"Tall"})); + assert_eq!(s.doc, before); + assert_eq!(s.revision, revision); + assert_eq!(s.undo.len(), history); + assert_eq!(preview["styles"][0]["dormant"], false); + run(&mut s, "config.activate", json!({"row":"Tall"})); + let state = s.model.state(); + let mb = state.body(&body).unwrap(); + let colors = s.doc.face_colors(mb); + assert_eq!(colors.len(), 1); + let names = solvecraft_doc::naming::face_names(mb); + assert!(names[colors[0].0].ends_with(":+z")); + assert!(s.doc.appearances.faces[0].strict_name); + run(&mut s, "appearance.assign", json!({"faces":[{"body":body,"point":[5,5,20]}]})); + assert!(s.doc.appearances.faces.is_empty(), "clear resolves semantic name after configured resize"); + run(&mut s, "config.activate", json!({"row":"Tall"})); + run(&mut s, "body.rename", json!({"body":body,"name":"Housing"})); + let key = s.doc.config_key(&s.doc.configs.columns[0]); + let suppress = format!("suppress:{feature}"); + run(&mut s, "config.row", json!({"name":"Hidden","values":{key:"ABS - Blue",suppress:true}})); + let hidden = run(&mut s, "config.activate", json!({"row":"Hidden"})); + assert_eq!(hidden["styles"][0]["dormant"], true); + assert!(s.model.state().bodies.is_empty()); + let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(reopened, *s.doc); + run(&mut s, "config.activate", json!({"row":"Tall"})); + assert_eq!(s.doc.face_colors(s.model.state().body("Housing").unwrap())[0].1.name, "ABS - Red"); + run(&mut s, "config.activate", json!({"row":"Default"})); + assert!(s.doc.appearances.faces.is_empty()); + let before_delete = s.doc.clone(); + run(&mut s, "timeline.delete", json!({"feature":feature})); + assert!(s.doc.configs.columns.is_empty()); + assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty())); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before_delete); + run(&mut s, "config.activate", json!({"row":"Tall"})); + assert!(s.doc.appearances.faces[0].strict_name); +} + +#[test] +fn strict_face_split_pieces_share_lineage_without_index_targets() { + let mut s = Session::default(); + let body = block(&mut s); + let key = face_column(&mut s, &body, json!([5, 5, 10])); + run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}})); + run(&mut s, "solid.split_face", json!({"body":body,"faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}})); + run(&mut s, "config.activate", json!({"row":"Paint"})); + let state = s.model.state(); + let body = state.body(&body).unwrap(); + let colors = s.doc.face_colors(body); + let names = solvecraft_doc::naming::face_names(body); + assert!(colors.len() >= 2, "split should create multiple painted pieces: {names:?}"); + assert!(colors.iter().all(|(index, look)| solvecraft_doc::naming::strip_piece(&names[*index]).ends_with(":+z") && look.name == "ABS - Red")); +} + +#[test] +fn strict_face_missing_lineage_never_retargets_and_rolls_back() { + let mut s = Session::default(); + let body = block(&mut s); + let key = face_column(&mut s, &body, json!([5, 5, 10])); + run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[30,0,0],"operation":"new"})); + let other = s.doc.features.last().unwrap().id; + let Column::FaceAppearance(target) = &mut s.doc_mut().configs.columns[0] else { panic!() }; + target.origin_feature = other; + target.name = format!("F{other}:+z"); + let before = s.doc.clone(); + let revision = s.revision; + let history = s.undo.len(); + assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err()); + assert!(s.execute("config.preview", &json!({"row":"Paint"})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.revision, revision); + assert_eq!(s.undo.len(), history); + let look = solvecraft_doc::appearance::library("ABS - Red").unwrap(); + s.doc_mut().appearances.faces.push(solvecraft_doc::appearance::FaceLook { + body: body.clone(), + point: solvecraft_geom::Vec3::new(5., 5., 10.), + name: Some(format!("F{other}:+z")), + strict_name: true, + certificate: None, + look, + }); + assert!(s.doc.face_colors(s.model.state().body(&body).unwrap()).is_empty()); + s.doc_mut().appearances.faces[0].strict_name = false; + assert_eq!(s.doc.face_colors(s.model.state().body(&body).unwrap()).len(), 1); +} + +#[test] +fn face_occurrence_pick_uses_definition_identity_and_coordinates() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap(); + let body = block(&mut s); + run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]})); + let world = s.world_state(); + let placed = world.bodies.iter().find(|body| body.name != body.definition_name()).unwrap(); + let placed_name = placed.name.clone(); + let point = solvecraft_doc::apply_point(&placed.source_transform, solvecraft_geom::Vec3::new(5., 5., 10.)); + let key = face_column(&mut s, &placed_name, json!(point)); + let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() }; + assert_eq!(target.body, body); + assert!(target.point.dist(solvecraft_geom::Vec3::new(5., 5., 10.)) < 1e-8); + run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Blue"}})); + run(&mut s, "config.activate", json!({"row":"Paint"})); + assert!(s.world_state().bodies.iter().all(|body| s.doc.face_colors(body).len() == 1)); +} + +#[test] +fn extrude_cap_certification_follows_actual_positive_negative_and_symmetric_names() { + for direction in ["positive", "negative", "symmetric"] { + 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":10,"direction":direction,"body_name":"Pad"})); + let state = s.model.state(); + let body = state.body("Pad").unwrap(); + let faces = body.body.faces(0.01).unwrap(); + let names = solvecraft_doc::naming::face_names(body); + let mut caps = 0; + for face in faces { + let name = solvecraft_doc::naming::strip_piece(&names[face.index]); + let origin = name.strip_prefix('F').unwrap().split_once(':').unwrap().0.parse().unwrap(); + let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid, certificate: None }; + if name.ends_with(":start") || name.ends_with(":top") || name.ends_with(":bottom") { + s.doc.certify_config_face(&target).unwrap(); + s.doc.configuration_face_indices(body, &target).unwrap(); + caps += 1; + } else { + assert!(s.doc.certify_config_face(&target).is_err()); + } + } + assert_eq!(caps, 2, "{direction}"); + } +} + +#[test] +fn ordinary_extent_edits_never_transfer_strict_cap_paint_to_ambiguous_caps() { + 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":10,"body_name":"Pad","name":"PadFeature","start_offset":"0 mm"})); + let key = face_column(&mut s, "Pad", json!([5, 5, 10])); + run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}})); + run(&mut s, "config.activate", json!({"row":"Paint"})); + assert_eq!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).len(), 1); + run(&mut s, "timeline.redefine", json!({"feature":"PadFeature","command":"solid.extrude","params":{"distance":10,"start_offset":5}})); + assert!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).is_empty(), "ordinary offset edit must not paint both new top-named caps"); + let before = s.doc.clone(); + assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err()); + assert_eq!(s.doc, before); +} + +#[test] +fn hostile_and_uncertified_face_targets_are_rejected() { + let mut s = Session::default(); + let body = block(&mut s); + let id = s.doc.features[0].id; + let target = ConfigFaceTarget { + body: body.clone(), + origin_feature: id, + name: format!("F{id}:face:0"), + point: solvecraft_geom::Vec3::ZERO, + certificate: None, + }; + assert!(s.doc.certify_config_face(&target).is_err()); + for value in [ + json!({"body":body,"origin_feature":id,"name":"x".repeat(1_000_000),"point":[0,0,0]}), + json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,1e20]}), + json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,0],"extra":[[]]}), + ] { + assert!(solvecraft_doc::config::validate_styles_json(&json!({"configs":{"columns":[{"face_appearance":value}]}})).is_err()); + } + assert!(s.execute("config.column", &json!({"component_appearance":0})).is_err()); + assert!(s.execute("config.column", &json!({"face_appearance":{"body":body,"point":[0,0,1e20]}})).is_err()); +} diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs new file mode 100644 index 00000000..21955181 --- /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.model_body_component(b) == 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.model_body_offset(body).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/delete.rs b/crates/engine/src/cmd/delete.rs index ec2498ca..8b35169c 100644 --- a/crates/engine/src/cmd/delete.rs +++ b/crates/engine/src/cmd/delete.rs @@ -65,8 +65,12 @@ fn apply(s: &mut Session, p: &Value) -> Result { let mut features: Vec = Vec::new(); let mut bodies: Vec = Vec::new(); let mut faces: Vec<(String, Vec)> = Vec::new(); + let mut selected_occurrences = Vec::new(); for x in &items { match x { + Sel::Occurrence { path } => { + selected_occurrences.push(s.occurrence_placement(path)?.occurrence.to_string()); + } Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()), Sel::Feature { id } => { if s.doc.feature(*id).is_none() { @@ -118,7 +122,10 @@ fn apply(s: &mut Session, p: &Value) -> Result { } // An occurrence goes alone while its component has others; the last one takes the component. let mut components: Vec = Vec::new(); - for k in keys(p, "occurrences") { + selected_occurrences.extend(keys(p, "occurrences")); + selected_occurrences.sort(); + selected_occurrences.dedup(); + for k in selected_occurrences { let Some(o) = s.doc.occurrences.iter().find(|o| o.id.to_string() == k || o.name == k).cloned() else { return Err(bad(cmd, format!("no occurrence `{k}`"))); }; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cdfcd4cc..45ed6950 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -47,9 +47,10 @@ pub static COMMANDS: &[CommandSpec] = &[ fn undo(s: &mut Session, _p: &Value) -> Result { let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) }; let label = prev.label.clone(); - s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch }); + s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() }); s.doc = prev.doc; s.active_sketch = prev.active_sketch; + s.pending_moves = prev.pending_moves; s.selection.clear(); s.refresh(); Ok(json!({"undone": label})) @@ -58,9 +59,10 @@ fn undo(s: &mut Session, _p: &Value) -> Result { fn redo(s: &mut Session, _p: &Value) -> Result { let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) }; let label = next.label.clone(); - s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch }); + s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() }); s.doc = next.doc; s.active_sketch = next.active_sketch; + s.pending_moves = next.pending_moves; s.selection.clear(); s.refresh(); Ok(json!({"redone": label})) @@ -200,6 +202,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) { return Err(bad(cmd, "the feature type cannot change")); } + if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &nf.kind { + solvecraft_doc::validate_body_transfer(&s.doc, bodies, *from, *to, transform)?; + nf.component = *to; + } if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind) && a != b { @@ -238,6 +244,9 @@ fn select_set(s: &mut Session, p: &Value) -> Result { let mut sel: Vec = Vec::new(); for it in items { let one: Sel = serde_json::from_value(it).map_err(|e| bad("select.set", e.to_string()))?; + if let Sel::Occurrence { path } = &one { + s.occurrence_bodies(path, true).map_err(|e| bad("select.set", e.to_string()))?; + } sel.push(one); } if !bool_(p, "add").unwrap_or(false) { @@ -296,36 +305,169 @@ 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 let FeatureKind::Revolve { convention, .. } = &old.kind + && cid == "solid.revolve" + && let Some(parameters) = params.as_object_mut() + { + parameters.entry("convention").or_insert_with(|| json!(convention)); + } + if !old.participants.is_empty() + && let Some(parameters) = params.as_object_mut() + { + parameters.entry("participants").or_insert_with(|| json!(old.participants)); + } + // 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; + if let FeatureKind::Emboss { face: Some(face), .. } = &old.kind { + trial.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone()); + } + trial.active_sketch = None; + trial.selection = s.selection.clone(); + // Keep the original identity as a suppressed placeholder. The constructor and + // any ownership boundary it adds go AFTER this slot, so discarding its temporary + // feature leaves auxiliary history after the rewritten original. + if let Some(slot) = trial.doc_mut().feature_mut(id) { + slot.suppressed = true; + } + trial.doc_mut().marker = Some(idx + 1); + 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 mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; + if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind) + && before.body == after.body + && before.path == after.path + && before.author_path == after.author_path + && before.at.dist(after.at) < 1e-8 + { + *after = before.clone(); } - s.doc_mut().marker = marker; - r?; - let new = new.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.suppressed = old.suppressed; + 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)); + } + let original_ids: std::collections::BTreeSet = s.doc.features.iter().map(|feature| feature.id).collect(); + let added_ids: std::collections::BTreeSet = + trial.doc.features.iter().filter(|feature| !original_ids.contains(&feature.id)).map(|feature| feature.id).collect(); + let adds_transfer = + trial.doc.features.iter().any(|feature| added_ids.contains(&feature.id) && matches!(feature.kind, FeatureKind::BodyTransfer { .. })); + let baseline = if adds_transfer && s.doc.marker.is_some() { + let mut baseline = s.scratch(); + baseline.doc_mut().marker = None; + baseline.refresh(); + Some(baseline) + } else { + None + }; + let proof = if (adds_transfer && trial.doc.marker.is_some()) || added_ids.iter().any(|id| trial.model.result(*id).is_none()) { + let mut proof = trial.scratch(); + proof.doc_mut().marker = None; + proof.refresh(); + Some(proof) + } else { + None + }; + let validated = proof.as_ref().map(|proof| &proof.model).unwrap_or(&trial.model); + let original_model = baseline.as_ref().map(|baseline| &baseline.model).unwrap_or(&s.model); + for feature in &trial.doc.features { + let auxiliary = added_ids.contains(&feature.id); + let protected_transfer = matches!(feature.kind, FeatureKind::BodyTransfer { .. }) + && original_model.result(feature.id).is_some_and(|result| !result.skipped && result.error.is_none()); + if (auxiliary || protected_transfer) + && let Some(error) = validated.result(feature.id).and_then(|result| result.error.as_ref()) + { + return Err(bad(cmd, format!("{}: {error}", feature.name))); + } + } + s.pending_moves = trial.pending_moves; + 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 +486,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..1d1dd86e --- /dev/null +++ b/crates/engine/src/cmd/edit_tests.rs @@ -0,0 +1,312 @@ +//! 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"})); + let original = s.doc.clone(); + let original_feature = s.doc.find_feature("Join").unwrap().id; + run( + &mut s, + "timeline.redefine", + json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}), + ); + let destination = s.doc.find_component("Merged").unwrap(); + assert_eq!(s.model.state().body("Base").unwrap().component, destination); + assert_eq!(s.doc.find_feature("Join").unwrap().id, original_feature); + let combine_index = s.doc.feature_index(original_feature).unwrap(); + assert!( + matches!(&s.doc.features[combine_index + 1].kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Base"] && *to == destination) + ); + assert!((solvecraft_kernel::measure(&s.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6); + assert_eq!(s.world_state().bodies.len(), 1); + run(&mut s, "timeline.rollback", json!({"position":combine_index + 1})); + assert_eq!(s.world_state().body("Base").unwrap().component, 0, "the result transfers only AFTER Combine"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*s.doc, *original); + run(&mut s, "edit.redo", json!({})); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(reopened.world_state().body("Base").unwrap().component, destination); + assert!((solvecraft_kernel::measure(&reopened.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6); + 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_cannot_publish_a_second_transfer_that_breaks_existing_ownership() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Base"})); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[1,0,0],"body_name":"Tool"})); + run(&mut s, "solid.combine", json!({"target":"Base","tools":["Tool"],"name":"Join","new_component":true,"component_name":"Original"})); + let full = s.doc.to_json(); + let join_index = s.doc.features.iter().position(|feature| feature.name == "Join").unwrap(); + for marker in [None, Some(join_index), Some(join_index + 1)] { + let mut s = Session::new(solvecraft_doc::Document::from_json(&full).unwrap()); + s.doc_mut().marker = marker; + s.refresh(); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let error = s.execute("timeline.redefine", &json!({"feature":"Join","command":"solid.combine","params":{"target":"Base","tools":["Tool"],"new_component":true,"component_name":"Duplicate"}})).unwrap_err(); + assert!(error.to_string().contains("transfer source component"), "marker {marker:?}: {error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + } + let reopened = Session::new(solvecraft_doc::Document::from_json(&full).unwrap()); + assert_eq!(reopened.world_state().body("Base").unwrap().component, reopened.doc.find_component("Original").unwrap()); + assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none()))); +} + +#[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/face.rs b/crates/engine/src/cmd/face.rs index 8a13da8f..ca66bb49 100644 --- a/crates/engine/src/cmd/face.rs +++ b/crates/engine/src/cmd/face.rs @@ -3,6 +3,7 @@ //! edges become lines, circular edges circles or arcs, anything else a chain of lines. use serde_json::{Value, json}; +use solvecraft_doc::{EmbossFace, apply_point, mat_inverse, mat_mul}; use solvecraft_geom::{Mesh, Vec2, Vec3}; use crate::params::bad; @@ -19,7 +20,7 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { /// The outward normal of the planar body face at `p`. pub(super) fn face_normal(s: &Session, p: Vec3) -> Option { - let (m, f, d) = face_at(s, p)?; + let (m, f, d, _) = face_at(s, p)?; let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); if d > tol * 10.0 { return None; @@ -34,7 +35,7 @@ pub(super) fn face_normal(s: &Session, p: Vec3) -> Option { /// Is the body face at `p` planar (`None`: no face there)? pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option { - let (m, f, d) = face_at(s, p)?; + let (m, f, d, _) = face_at(s, p)?; let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); if d > tol * 10.0 { return None; @@ -52,9 +53,10 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option { } /// The body face under a point: (mesh, face index, distance). -fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64)> { +fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64, String)> { let st = s.model.state(); - let mut best: Option<(std::sync::Arc, u32, f64)> = None; + let mut best: Option<(std::sync::Arc, u32, f64, String)> = None; + let mut ambiguous = false; for b in &st.bodies { let m = b.mesh(); let mut near: Option<(u32, f64)> = None; @@ -65,13 +67,16 @@ fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64)> { near = Some((*f, d)); } } - if let Some((f, d)) = near - && best.as_ref().is_none_or(|x| d < x.2) - { - best = Some((m.clone(), f, d)); + if let Some((f, d)) = near { + if best.as_ref().is_none_or(|x| d < x.2 - 1e-8) { + best = Some((m.clone(), f, d, b.name.clone())); + ambiguous = false; + } else if best.as_ref().is_some_and(|x| (d - x.2).abs() <= 1e-8) { + ambiguous = true; + } } } - best + if ambiguous { None } else { best } } /// Circle through three points. @@ -93,7 +98,7 @@ fn xy(v: Vec2) -> Value { /// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns /// the sketch id and a point inside the face (sketch coordinates). The sketch is left active. pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> { - let (m, f, d) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?; + let (m, f, d, body) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?; let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); if d > tol * 10.0 { return Err(bad(cmd, "no body face at `face`")); @@ -118,14 +123,45 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result< let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?; (spec.run)(s, &p) }; - let r = run(s, "sketch.create", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?; + // Command frame normalization already made `point` definition-local. Capture takes + // world coordinates; convert exactly once using the picked body's actual occurrence. + let state = s.model.state(); + let doc = s.placed_document(); + let picked = state.body(&body).ok_or_else(|| bad(cmd, "picked body disappeared"))?; + let component = doc.model_body_component(picked); + let path = if component == s.active_component { + s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component)) + } else { + // The command mapper discarded the original placement when making this pick + // definition-local. A repeated cross-owner source cannot be re-found safely. + let placements = super::component::properties::placements(&doc, cmd)?; + if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 { + return Err(bad(cmd, "activate the picked occurrence before generating a face profile from a repeated component")); + } + doc.first_path(component) + }; + let placement = EmbossFace::component_frame(&doc, component, &path)?; + let placement = doc.model_body_offset(picked).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement); + let world_inside = apply_point(&placement, inside); + let world_body = doc.world_body_name(&state, &body, component, &path); + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component)); + let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?; + let author_inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?; + let author_inside = apply_point(&author_inverse, world_inside); + drop(doc); + let r = super::sketch::create_face_profile( + s, + &json!({"plane": {"face": [world_inside.x, world_inside.y, world_inside.z], "body": world_body, "index": f, "path":path}, "project_edges": true}), + &body, + f as usize, + )?; let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?; s.refresh(); let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?; // The face's edges came in as linked projections: they follow the face when earlier // features change, so nothing is copied. if r.get("projected").is_some_and(|x| !x.is_null()) { - return Ok((sid, plane.to_local(inside))); + return Ok((sid, plane.to_local(author_inside))); } for e in &edges { let pts: Vec = e.iter().map(|p| plane.to_local(*p)).collect(); @@ -163,5 +199,9 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result< } }; } - Ok((sid, plane.to_local(inside))) + Ok((sid, plane.to_local(author_inside))) } + +#[cfg(test)] +#[path = "face_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/face_tests.rs b/crates/engine/src/cmd/face_tests.rs new file mode 100644 index 00000000..ed46d6c4 --- /dev/null +++ b/crates/engine/src/cmd/face_tests.rs @@ -0,0 +1,187 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_doc::{Document, FeatureKind, PlaneRef}; +use solvecraft_geom::Vec3; +use std::f64::consts::PI; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) +} +fn check(s: &mut Session, want: f64, hole: bool, height: f64) { + s.refresh(); + assert!(s.model.results.iter().all(|result| result.error.is_none())); + let state = s.model.state(); + let got: f64 = state.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum(); + // Legacy linked outlines fit tessellated curves; enforce the existing 1e-4 material envelope. + assert!((got - want).abs() < want * 1e-4, "{got} vs {want}"); + assert!(state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height - 1.0)))); + assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height + 1.0)))); + if hole { + assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(20.0, 15.0, height - 1.0)))); + } +} +#[test] +fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_edit_and_reopen() { + for hole in [false, true] { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"})); + if hole { + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"})); + } + let result = run(&mut s, "solid.extrude", json!({"face":[5,5,20],"distance":10,"operation":"new","name":"Pad"})); + let pad = s.doc.feature(result["feature"].as_u64().unwrap()).unwrap(); + let FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") }; + let generated = s.doc.feature(sketch).unwrap(); + let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") }; + assert!(matches!(plane,PlaneRef::Face{anchor,..} if anchor.is_some()!=hole)); + assert!(!profile.links.is_empty()); + assert!(profile.links.iter().all(|link| link.auto_face != hole && !link.lost)); + let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 }; + check(&mut s, area * 30.0, hole, 30.0); + run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"height":"25","length":"50"}})); + let area = 1500.0 - if hole { 25.0 * PI } else { 0.0 }; + check(&mut s, area * 35.0, hole, 35.0); + let edited = s.model.state(); + assert!(edited.bodies.iter().any(|body| body.body.contains(Vec3::new(49.0, 5.0, 34.0)))); + assert!(!edited.bodies.iter().any(|body| body.body.contains(Vec3::new(51.0, 5.0, 34.0)))); + assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| !link.lost)); + let solved = edited.sketch(sketch).unwrap().plane; + assert!((solved.origin.z - 25.0).abs() < 1e-6); + let before = s.model.state(); + let before_volume: f64 = before.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum(); + let before_bounds: Vec<_> = before.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect(); + let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap()); + check(&mut reopened, area * 35.0, hole, 35.0); + let after = reopened.model.state(); + let after_volume: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum(); + assert!((before_volume - after_volume).abs() < 1e-6); + for (name, bounds) in before_bounds { + let cold = after.body(&name).unwrap().mesh().bounds(); + assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9); + } + } +} +#[test] +fn internal_capture_does_not_bypass_missing_ambiguous_or_invalid_path_picks() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"A"})); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"B"})); + let before = s.doc.to_json(); + assert!(s.execute("solid.extrude", &json!({"face":[5,5,20],"distance":10,"operation":"new"})).is_err()); + assert_eq!(s.doc.to_json(), before); + for pick in [json!({"face":[5,5,20]}), json!({"face":[500,5,20]}), json!({"face":[5,5,20],"body":"A","index":0,"path":[999999]})] { + assert!(super::super::sketch::face::capture_internal(&s, &pick, "internal").is_err(), "{pick}"); + } +} + +#[test] +fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() { + for hole in [false, true] { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Arm"})); + let cid = component["component"].as_u64().unwrap(); + let occ = component["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base","body_name":"Stock"})); + if hole { + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"})); + } + run(&mut s, "component.activate", json!({"component":0})); + run(&mut s, "occurrence.move", json!({"occurrence":occ,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true})); + run(&mut s, "component.activate", json!({"component":cid,"occurrence":occ})); + run(&mut s, "solid.extrude", json!({"face":[35,0,15],"distance":10,"operation":"join"})); + let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 }; + check(&mut s, area * 30.0, hole, 30.0); + run(&mut s, "solid.press_pull", json!({"face":[35,-10,15],"distance":5})); + check(&mut s, area * 35.0, hole, 35.0); + let height = if hole { + // Curved coincident Cut is an existing kernel limit; it must be atomic. + let before = s.doc.to_json(); + let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err(); + assert!(error.to_string().contains("coincident faces on curved bodies"), "{error}"); + assert_eq!(s.doc.to_json(), before); + check(&mut s, area * 35.0, hole, 35.0); + 35.0 + } else { + run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5})); + check(&mut s, area * 30.0, hole, 30.0); + 30.0 + }; + let world = s.world_state(); + let occupied = Vec3::new(35.0, 21.0 - height, 15.0); + let absent = Vec3::new(35.0, 19.0 - height, 15.0); + assert!(world.bodies.iter().any(|body| body.body.contains(occupied))); + assert!(!world.bodies.iter().any(|body| body.body.contains(absent))); + if hole { + assert!(!world.bodies.iter().any(|body| body.body.contains(Vec3::new(50.0, 21.0 - height, 25.0)))); + } + let before_volume: f64 = world.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum(); + let before_bounds: Vec<_> = world.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect(); + let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap()); + let after = reopened.world_state(); + assert!(after.bodies.iter().any(|body| body.body.contains(occupied))); + let v: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum(); + assert!((v - area * height).abs() < area * height * 1e-4); + assert!((v - before_volume).abs() < 1e-6); + for (name, bounds) in before_bounds { + let cold = after.body(&name).unwrap().mesh().bounds(); + assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9); + } + } +} + +#[test] +fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Source"})); + let occurrence = component["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"})); + run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5})); + run(&mut s, "component.activate", json!({"component":0})); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false})); + let state = s.model.state(); + let index = state + .body("Stock") + .unwrap() + .body + .faces(1e-4) + .unwrap() + .iter() + .find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6) + .unwrap() + .index; + let before = s.doc.to_json(); + let error = + super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal") + .unwrap_err(); + assert!(error.to_string().contains("capture the source occurrence position"), "{error}"); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Repeated"})); + let cid = component["component"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})); + run(&mut s, "component.activate", json!({"component":0})); + let copied = run(&mut s, "occurrence.copy", json!({"component":cid,"translate":[50,0,0]})); + let occurrence = copied["occurrence"].as_u64().unwrap(); + let before = s.doc.to_json(); + let error = s.execute("solid.extrude", &json!({"face":[55,5,10],"distance":5,"operation":"new"})).unwrap_err(); + assert!(error.to_string().contains("activate the picked occurrence"), "{error}"); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "component.activate", json!({"component":cid,"occurrence":occurrence})); + run(&mut s, "solid.extrude", json!({"face":[55,5,10],"distance":5,"operation":"new"})); + let state = s.world_state(); + for x in [5.0, 55.0] { + assert!(state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 14.0)))); + assert!(!state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 16.0)))); + } +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 13641d0f..755fec72 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve") - .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"), + .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"), CommandSpec::new("solid.sweep", "Sweep", sweep) .at("SOLID", "CREATE") .icon("sweep") @@ -158,7 +158,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { let bodies: Vec<_> = st .bodies .iter() - .filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) }) + .filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) }) .collect(); if bodies.is_empty() { return Some("new"); @@ -321,7 +321,24 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu }; let face_names = { let pts = kind.face_points(); - if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) } + if let FeatureKind::Emboss { face: Some(face), .. } = &kind { + let state = s.model.state(); + let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?; + let frame = face.body_frame(&s.doc, body).map_err(|e| EngineError::Other(e.to_string()))?; + let inverse = solvecraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?; + let at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at); + let mut scoped = solvecraft_doc::ModelState::default(); + scoped.bodies.push(body.clone()); + let names = solvecraft_doc::naming::face_names_at(&scoped, &[at]); + if names.len() != 1 || names.first().is_none_or(|name| name.is_empty()) { + return Err(EngineError::Other("selected cylinder face has no persistent identity".into())); + } + names + } else if pts.is_empty() { + Vec::new() + } else { + solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) + } }; let participants = string_list(p, "participants"); if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) { @@ -465,6 +482,10 @@ fn revolve(s: &mut Session, p: &Value) -> Result { }; let symmetric = matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false); + let convention = match p.get("convention") { + None => solvecraft_doc::RevolveConvention::SketchNormal, + Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be sketch_normal or legacy_axis_start"))?, + }; let kind = FeatureKind::Revolve { sketch, profiles, @@ -474,6 +495,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result { targets: string_list(p, "targets"), angle2, symmetric, + convention, to, }; add_feature(s, p, kind) @@ -710,7 +732,7 @@ fn combine(s: &mut Session, p: &Value) -> Result { if bool_(p, "new_component").unwrap_or(false) { let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined")); let c = s.execute("component.create", &json!({"name": name, "activate": false}))?; - s.execute("component.move_bodies", &json!({"bodies": [target], "component": c["component"]}))?; + super::component::transfer_constructor_result(s, &json!({"bodies": [target], "component": c["component"]}))?; out["component"] = c["component"].clone(); } Ok(out) @@ -1325,3 +1347,7 @@ fn offset_face(s: &mut Session, p: &Value) -> Result { #[cfg(test)] #[path = "patterns_tests.rs"] mod patterns_tests; + +#[cfg(test)] +#[path = "revolve_convention_tests.rs"] +mod revolve_convention_tests; diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index d98a6c23..f512296c 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.emboss", "Emboss", emboss) .at("SOLID", "CREATE") .icon("emboss") - .params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"), + .params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (inferred when exactly one supported tangent cylinder matches the selected profiles)"), CommandSpec::new("solid.replace_face", "Replace Face", replace_face) .at("SOLID", "MODIFY") .icon("offset_face") @@ -143,14 +143,169 @@ fn emboss(s: &mut Session, p: &Value) -> Result { Some("deboss") => true, Some(m) => return Err(bad(cmd, format!("unknown mode `{m}` (emboss or deboss)"))), }; - if let Some(fp) = p.get("face").and_then(vec3) - && super::face::face_normal(s, fp).is_none() - { - return Err(bad(cmd, "not supported yet: embossing onto a curved face")); - } - add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets: string_list(p, "targets") }) + let mut targets = string_list(p, "targets"); + let face = if let Some(value) = p.get("face") { + let local = vec3(value).ok_or_else(|| bad(cmd, "`face` must be a finite point [x,y,z]"))?; + let at = solvecraft_doc::apply_point(&s.active_component_frame(), local); + let state = s.world_state(); + let mut candidates = Vec::new(); + for body in &state.bodies { + let definition = body.definition_name(); + if !targets.is_empty() && !targets.iter().any(|n| n == definition || n == &body.name) { + continue; + } + let mesh = body.mesh(); + let closest = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .filter_map(|(tri, face)| { + let [a, b, c] = mesh.tri(tri)?; + let n = (b - a).cross(c - a).normalized()?; + let q = at - n * (at - a).dot(n); + let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9); + inside.then_some(((at - a).dot(n).abs(), *face as usize)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)); + if let Some((distance, index)) = closest + && distance < (body.body.size() * 1e-3).max(1e-3) + { + candidates.push((body, index)); + } + } + if candidates.len() != 1 { + return Err(bad(cmd, "`face` must identify exactly one target body surface")); + } + let (selected, index) = candidates.first().copied().ok_or_else(|| bad(cmd, "no target face"))?; + let name = selected.definition_name(); + let body = &selected.body; + match solvecraft_kernel::face_surface(body, index) { + Some(solvecraft_kernel::FaceSurface::Plane { .. }) => { + targets = vec![name.to_owned()]; + None + } + Some(solvecraft_kernel::FaceSurface::Cylinder { .. }) => { + if targets.len() > 1 { + return Err(bad(cmd, "cylinder emboss needs one selected target body")); + } + // Viewport picks lie on tessellated chords. Keep their axial and + // angular position, and certify their distance before storing an + // exact surface anchor for later replay. + let at = solvecraft_kernel::cylinder_emboss_anchor(body, index, at).map_err(|e| bad(cmd, e.to_string()))?; + let face = emboss_selection(s, selected, at)?; + check_emboss_positions(s, sketch, selected, &face)?; + targets = vec![name.to_owned()]; + Some(face) + } + _ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")), + } + } else { + let inferred = infer_emboss_selection(s, sketch, &profiles, &depth, deboss, &targets)?; + if let Some(face) = &inferred { + targets = vec![face.body.clone()]; + } + inferred + }; + add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face }) } +fn emboss_selection(s: &Session, selected: &solvecraft_doc::ModelBody, at: Vec3) -> Result { + let cmd = "solid.emboss"; + let name = selected.definition_name(); + let original = s.model.state(); + let owner = s.doc.model_body_component(selected); + let paths: Vec<_> = s + .doc + .placements() + .into_iter() + .filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name) + .map(|(_, path, _)| path) + .collect(); + if paths.len() != 1 { + return Err(bad(cmd, "selected cylinder placement is ambiguous")); + } + let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?; + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component)); + if path.len() > 64 || author_path.len() > 64 { + return Err(bad(cmd, "cylinder placement is too deep")); + } + let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?; + Ok(solvecraft_doc::EmbossFace { body: name.to_owned(), at: solvecraft_doc::apply_point(&inverse, at), path, author_path }) +} + +fn check_emboss_positions(s: &Session, sketch: u64, body: &solvecraft_doc::ModelBody, face: &solvecraft_doc::EmbossFace) -> Result<()> { + let cmd = "solid.emboss"; + let owner = s.doc.model_body_component(body); + let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?; + let sketch_path = if sketch_owner == owner { + face.path.clone() + } else if sketch_owner == s.active_component { + face.author_path.clone() + } else { + s.doc.first_path(sketch_owner) + }; + if face.path.iter().chain(&face.author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) { + return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss")); + } + Ok(()) +} + +fn infer_emboss_selection( + s: &Session, + sketch: u64, + profiles: &solvecraft_doc::ProfileSel, + depth: &str, + deboss: bool, + targets: &[String], +) -> Result> { + let cmd = "solid.emboss"; + let original = s.model.state(); + let ss = original.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?; + let regions = solvecraft_doc::select_profiles(ss, profiles).map_err(|e| bad(cmd, e.to_string()))?; + let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?; + let d = s.doc.eval(&s.doc.with_design_unit(depth, Kind::Length), Kind::Length).map_err(|e| bad(cmd, e.to_string()))?; + let world = s.world_state(); + let placed = s.placed_document(); + let mut chosen = None; + let mut faces = 0usize; + for selected in &world.bodies { + let name = selected.definition_name(); + if !targets.is_empty() && !targets.iter().any(|n| n == name || n == &selected.name) { + continue; + } + let raw = original.body(name).ok_or_else(|| bad(cmd, "target definition no longer exists"))?; + faces = faces.saturating_add(raw.body.face_count()); + if faces > 50_000 { + return Err(bad(cmd, "cylinder target search exceeds face work limit")); + } + if !solvecraft_kernel::has_cylinder_emboss_wall(&raw.body).map_err(|e| bad(cmd, e.to_string()))? { + continue; + } + let mut face = emboss_selection(s, selected, Vec3::ZERO)?; + let body_frame = face.body_frame(&placed, raw).map_err(|e| bad(cmd, e.to_string()))?; + let inverse = solvecraft_doc::mat_inverse(&body_frame).ok_or_else(|| bad(cmd, "invalid cylinder body frame"))?; + let displayed = world.sketch(sketch).ok_or_else(|| bad(cmd, "the profile sketch is not displayed"))?; + let plane = solvecraft_geom::Plane::new( + solvecraft_doc::apply_point(&inverse, displayed.plane.origin), + solvecraft_doc::apply_vector(&inverse, displayed.plane.x), + solvecraft_doc::apply_vector(&inverse, displayed.plane.y), + ) + .ok_or_else(|| bad(cmd, "invalid cylinder sketch frame"))?; + if let Some((_, at)) = + solvecraft_kernel::infer_cylinder_emboss(&raw.body, &plane, ®ions, if deboss { -d } else { d }).map_err(|e| bad(cmd, e.to_string()))? + { + face.sketch_frame(&placed, s.active_component, sketch_owner, raw).map_err(|e| bad(cmd, e.to_string()))?; + check_emboss_positions(s, sketch, selected, &face)?; + let author_inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid author frame"))?; + face.at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&author_inverse, &body_frame), at); + if chosen.is_some() { + return Err(bad(cmd, "multiple tangent cylinders match the selected profiles; select a target face or body")); + } + chosen = Some(face); + } + } + Ok(chosen) +} fn face_points(p: &Value, k: &str, cmd: &str) -> Result> { let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?; if a.is_empty() || a.len() > 1000 { diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 078464e1..9fc84134 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -9,6 +9,17 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value { } } +fn auto_emboss_params(mut p: Value, auto: bool) -> Value { + if auto { + if let Some(params) = p.get_mut("params").and_then(Value::as_object_mut) { + params.remove("face"); + } else if let Some(object) = p.as_object_mut() { + object.remove("face"); + } + } + p +} + fn measure(s: &mut Session) -> Value { run(s, "inspect.measure", json!({})) } @@ -81,6 +92,293 @@ fn emboss_and_deboss_on_a_face() { assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err()); } +fn cylinder_badge() -> Session { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1 mm"})); + run(&mut s, "solid.cylinder", json!({"radius":"10 mm","height":"20 mm","body_name":"Sleeve"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + s +} + +fn exact_cylinder_volume(s: &Session) -> f64 { + s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume +} + +#[test] +fn cylinder_emboss_persists_selection_and_replays_radial_depth() { + use solvecraft_geom::Vec3; + let mut s = cylinder_badge(); + let before = exact_cylinder_volume(&s); + run( + &mut s, + "solid.emboss", + json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":"badge_depth","targets":["Sleeve"],"name":"Raised Badge"}), + ); + assert!((exact_cylinder_volume(&s) - before - 21.0).abs() < 0.02); + let state = s.model.state(); + let body = &state.body("Sleeve").unwrap().body; + assert_eq!(body.shell_count(), 1); + let mesh = body.tessellate_with(0.001, true).unwrap(); + assert!(mesh.contains(Vec3::new(-10.5 * 0.15_f64.sin(), 10.5 * 0.15_f64.cos(), 7.5))); + let feature = s.doc.find_feature("Raised Badge").unwrap(); + assert!( + matches!(&feature.kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"&&face.at.dist(Vec3::new(0.,10.,7.5))<1e-10) + ); + let saved = s.doc.to_json(); + let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&saved).unwrap().into(), ..Default::default() }; + loaded.refresh(); + assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results); + assert!((exact_cylinder_volume(&loaded) - before - 21.0).abs() < 0.02); + run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2 mm"})); + assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results); + assert!((exact_cylinder_volume(&loaded) - before - 44.0).abs() < 0.02); +} + +#[test] +fn cylinder_deboss_is_connected_and_unsupported_profile_failure_is_atomic() { + let mut s = cylinder_badge(); + let before = exact_cylinder_volume(&s); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[0,10,7.5],"depth":1,"mode":"deboss","targets":["Sleeve"]})); + assert!((exact_cylinder_volume(&s) - before + 19.0).abs() < 0.02); + assert_eq!(s.model.state().body("Sleeve").unwrap().body.shell_count(), 1); + let mut bad = cylinder_badge(); + run(&mut bad, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Circle Badge"})); + run(&mut bad, "sketch.circle.center", json!({"center":[0,7.5],"radius":1})); + run(&mut bad, "sketch.finish", json!({})); + let saved = bad.doc.to_json(); + let v = exact_cylinder_volume(&bad); + assert!(bad.execute("solid.emboss", &json!({"sketch":"Circle Badge","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})).is_err()); + assert_eq!(bad.doc.to_json(), saved); + assert_eq!(exact_cylinder_volume(&bad), v); +} + +#[test] +fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":"0.1mm","height":"0.2mm","body_name":"Tiny"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,0.1,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.02,0.05],"p1":[0.02,0.1]})); + run(&mut s, "sketch.finish", json!({})); + let mesh = s.model.state().bodies.first().unwrap().mesh(); + let pick = mesh + .triangles + .iter() + .filter_map(|tri| mesh.tri(tri)) + .map(|[a, b, c]| (a + b + c) / 3.) + .find(|p| p.y > 0.08 && p.z > 0.04 && p.z < 0.16) + .unwrap(); + assert!(((pick.x * pick.x + pick.y * pick.y).sqrt() - 0.1).abs() > 1e-7); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[pick.x,pick.y,pick.z],"depth":"0.01mm","targets":["Tiny"]})); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else { + panic!("missing face"); + }; + assert!(((face.at.x * face.at.x + face.at.y * face.at.y).sqrt() - 0.1).abs() < 1e-12); + assert_eq!(s.model.state().bodies.first().unwrap().body.shell_count(), 1); +} + +#[test] +fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() { + for (pick_offset, auto) in [(0., false), (80., false), (0., true), (80., true)] { + let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && matches!(id, "solid.emboss" | "timeline.redefine"))); + let mut s = Session::default(); + let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + let source_feature = s.model.state().bodies.first().unwrap().feature; + let source_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap()); + let before = exact_cylinder_volume(&s); + run(&mut s, "occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})); + let second = run(&mut s, "occurrence.copy", json!({"component":a,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":"root"})); + let author = run(&mut s, "component.create", json!({"name":"Author"}))["component"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]})); + let author_second = run(&mut s, "occurrence.copy", json!({"component":author,"translate":[0,100,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":author,"occurrence":author_second})); + run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1mm"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[40.+pick_offset,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + let placements = serde_json::to_value(&s.doc.occurrences).unwrap(); + let target = s + .world_state() + .bodies + .iter() + .find(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(40.5 + pick_offset, 0., 7.5))) + .unwrap() + .name + .clone(); + run( + &mut s, + "solid.emboss", + json!({"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}), + ); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else { + panic!("missing selection"); + }; + assert_eq!(face.path, if pick_offset == 0. { s.doc.first_path(a) } else { vec![second] }); + assert_eq!(face.author_path, vec![author_second]); + let state = s.model.state(); + let body = state.body("Sleeve").unwrap(); + assert_eq!(body.feature, source_feature); + assert_eq!(s.doc.body_component("Sleeve", body.feature), a); + assert_eq!(serde_json::to_value(&s.doc.occurrences).unwrap(), placements); + let selected = s.doc.features.last().unwrap().face_names.first().unwrap(); + assert!(!selected.is_empty() && source_names.contains(selected), "{selected:?} not in {source_names:?}"); + assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02); + let world = s.world_state(); + for x in [39.5, 119.5] { + assert!(world.bodies.iter().any(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(x, 0., 7.5)))); + } + let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() }; + loaded.refresh(); + assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results); + run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"})); + assert!((exact_cylinder_volume(&loaded) - before - 44.).abs() < 0.02); + assert_eq!(loaded.doc.body_component("Sleeve", loaded.model.state().body("Sleeve").unwrap().feature), a); + assert_eq!(serde_json::to_value(&loaded.doc.occurrences).unwrap(), placements); + let feature = loaded.doc.features.last().unwrap().clone(); + run(&mut loaded, "component.activate", json!({"component":"root"})); + run( + &mut loaded, + "timeline.redefine", + json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}}), + ); + assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind); + assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names); + run( + &mut loaded, + "timeline.redefine", + json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"3mm","targets":[target]}}), + ); + assert!((exact_cylinder_volume(&loaded) - before - 69.).abs() < 0.02); + let mut broken = loaded.doc.as_ref().clone(); + let id = broken.features.last().unwrap().id; + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &mut broken.feature_mut(id).unwrap().kind else { + panic!("missing selection"); + }; + face.path = vec![u64::MAX]; + let mut invalid = Session { doc: broken.into(), ..Default::default() }; + invalid.refresh(); + assert!(invalid.model.results.iter().find(|r| r.id == id).unwrap().error.as_ref().unwrap().contains("placement")); + assert!((exact_cylinder_volume(&invalid) - before).abs() < 0.02); + } +} + +#[test] +fn cylinder_active_second_source_occurrence_authors_shared_geometry() { + check_active_second_source(false); + check_active_second_source(true); +} +fn check_active_second_source(auto: bool) { + let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss")); + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + let before = exact_cylinder_volume(&s); + let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[80,10,7.5],"depth":1})); + assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else { + panic!("missing selection"); + }; + assert_eq!(face.path, vec![second]); + assert_eq!(face.author_path, vec![second]); + let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() }; + loaded.refresh(); + assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results); + assert!((exact_cylinder_volume(&loaded) - before - 21.).abs() < 0.02); +} + +#[test] +fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() { + for (ancestor, auto) in [(false, false), (true, false), (false, true), (true, true)] { + let mut s = Session::default(); + let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap(); + let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "occurrence.move", json!({"occurrence":if ancestor {parent}else{leaf},"translate":[5,0,0],"capture":false})); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let volume = exact_cylinder_volume(&s); + let error = s + .execute("solid.emboss", &auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1}), auto)) + .unwrap_err() + .to_string(); + assert!(error.contains("Capture Position"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + assert_eq!(exact_cylinder_volume(&s), volume); + } +} + +#[test] +fn cylinder_pending_third_component_profile_requires_capture_atomically() { + check_pending_third_profile(false); + check_pending_third_profile(true); +} +fn check_pending_third_profile(auto: bool) { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "component.activate", json!({"component":"root"})); + run(&mut s, "component.create", json!({"name":"Author"})); + run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[5,0,0],"capture":false})); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let volume = exact_cylinder_volume(&s); + let error = s + .execute( + "solid.emboss", + &auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}), auto), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("Capture Position"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + assert_eq!(exact_cylinder_volume(&s), volume); +} + +#[test] +fn cylinder_body_offset_keeps_exact_profile_and_anchor_frames() { + check_body_offset(false); + check_body_offset(true); +} +fn check_body_offset(auto: bool) { + let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss")); + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + let before = exact_cylinder_volume(&s); + let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]})); + std::sync::Arc::make_mut(&mut s.doc).body_components.insert("Sleeve".into(), holder); + std::sync::Arc::make_mut(&mut s.doc) + .body_offsets + .insert("Sleeve".into(), [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., -50., 0., 1.]]); + s.refresh(); + assert!(s.doc.body_offsets.contains_key("Sleeve")); + let offset = s.doc.body_offsets["Sleeve"]; + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})); + assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02); + assert_eq!(s.doc.body_offsets["Sleeve"], offset); + assert_eq!(s.doc.body_component("Sleeve", s.model.state().body("Sleeve").unwrap().feature), holder); + assert!(s.world_state().body("Sleeve").unwrap().body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(0., 10.5, 7.5))); +} + #[test] fn replace_face_align_remove() { let mut s = Session::default(); @@ -137,3 +435,122 @@ fn coil_and_loft_to_a_point() { assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6); assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err()); } + +#[test] +fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() { + use solvecraft_geom::Vec3; + for mode in ["emboss", "deboss"] { + let mut s = cylinder_badge(); + let before = exact_cylinder_volume(&s); + let initial = s.doc.to_json(); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode,"name":"Auto Badge"})); + let feature = s.doc.find_feature("Auto Badge").unwrap().clone(); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), targets, .. } = &feature.kind else { + panic!("missing inferred association"); + }; + assert_eq!(targets, &["Sleeve"]); + assert_eq!(face.body, "Sleeve"); + assert!(face.at.z > 0. && face.at.z < 20.); + assert!((face.at.x.hypot(face.at.y) - 10.).abs() < 1e-10); + assert!(!feature.face_names.first().unwrap().is_empty()); + let expected = if mode == "emboss" { 21. } else { -19. }; + assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02); + let state = s.model.state(); + let body = &state.body("Sleeve").unwrap().body; + assert_eq!(body.shell_count(), 1); + assert_eq!( + body.tessellate_with(0.001, true).unwrap().contains(Vec3::new(0., if mode == "emboss" { 10.5 } else { 9.5 }, 7.5)), + mode == "emboss" + ); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), initial); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.find_feature("Auto Badge").unwrap().kind, feature.kind); + let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() }; + loaded.refresh(); + assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results); + run( + &mut loaded, + "timeline.redefine", + json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode}}), + ); + assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind); + assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names); + run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"})); + assert!((exact_cylinder_volume(&loaded) - before - if mode == "emboss" { 44. } else { -36. }).abs() < 0.02); + } +} + +#[test] +fn cylinder_auto_inference_ignores_remote_finite_tangent_walls_in_each_body_order() { + for remote_first in [false, true] { + let mut s = Session::default(); + for (name, z) in if remote_first { [("Remote", 100.), ("Sleeve", 0.)] } else { [("Sleeve", 0.), ("Remote", 100.)] } { + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"base":[0,0,z],"body_name":name})); + } + run(&mut s, "solid.cylinder", json!({"radius":8,"height":10,"base":[0,0,120],"body_name":"Remote Step"})); + run(&mut s, "solid.combine", json!({"target":"Remote","tools":["Remote Step"],"operation":"join"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + let before = exact_cylinder_volume(&s); + let remote = s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1})); + assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02); + assert_eq!(s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume, remote); + } +} + +#[test] +fn cylinder_auto_inference_retains_selected_holes_and_nested_islands() { + use solvecraft_geom::Vec3; + for mode in ["emboss", "deboss"] { + let mut s = cylinder_badge(); + run(&mut s, "sketch.edit", json!({"sketch":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-1,6],"p1":[1,9]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.5,6.5],"p1":[0.5,8.5]})); + run(&mut s, "sketch.finish", json!({})); + let before = exact_cylinder_volume(&s); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":[{"point":[1.5,7.5]},{"point":[0,7.5]}],"depth":1,"mode":mode})); + let depth = if mode == "emboss" { 1. } else { -1. }; + let expected = 16. * (depth + depth * depth / 20.); + assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02); + let st = s.model.state(); + let body = &st.body("Sleeve").unwrap().body; + assert_eq!(body.shell_count(), 1); + let mesh = body.tessellate_with(0.001, true).unwrap(); + for (s, raised) in [(0., true), (0.75, false), (1.5, true)] { + let theta = s / 10.0_f64; + let radius = if mode == "emboss" { 10.5 } else { 9.5 }; + assert_eq!(mesh.contains(Vec3::new(-radius * theta.sin(), radius * theta.cos(), 7.5)), if mode == "emboss" { raised } else { !raised }); + } + } +} + +#[test] +fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically() { + let mut s = cylinder_badge(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Coincident"})); + let before = s.doc.to_json(); + let error = s.execute("solid.emboss", &json!({"sketch":"Badge","depth":1})).unwrap_err().to_string(); + assert!(error.contains("multiple tangent cylinders"), "{error}"); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1,"targets":["Sleeve"]})); + assert!(matches!(&s.doc.features.last().unwrap().kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve")); + for circle in [false, true] { + let mut s = cylinder_badge(); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Unsupported"})); + if circle { + run(&mut s, "sketch.circle.center", json!({"center":[0,7.5],"radius":1})); + } else { + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-20,5],"p1":[20,10]})); + } + run(&mut s, "sketch.finish", json!({})); + let before = s.doc.to_json(); + let volume = exact_cylinder_volume(&s); + let error = s.execute("solid.emboss", &json!({"sketch":"Unsupported","depth":1})).unwrap_err().to_string(); + assert!(error.contains(if circle { "orthogonal line" } else { "seam" }), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(exact_cylinder_volume(&s), volume); + } +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 342a29e7..c83ae954 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -62,13 +62,14 @@ fn measure(s: &mut Session, p: &Value) -> Result { } let want = string_list(p, "bodies"); let st = s.world_state(); - let mut bodies: Vec = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect(); + let selected: Vec<_> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).collect(); + let mut bodies: Vec = selected.iter().map(|b| measure_json(b)).collect(); // Mass from each body's material. - for b in &mut bodies { - let name = b["name"].as_str().unwrap_or_default().to_string(); + for (b, body) in bodies.iter_mut().zip(selected) { if let Some(v) = b["volume_mm3"].as_f64() { - let rho = s.doc.density(&name); - b["material"] = json!(s.doc.materials.get(&name).cloned().unwrap_or_else(|| "Default".into())); + let source = body.definition_name(); + let rho = s.doc.density(source); + b["material"] = json!(s.doc.materials.get(source).cloned().unwrap_or_else(|| "Default".into())); b["density_g_cm3"] = json!(rho); b["mass_g"] = json!(v * rho / 1000.0); } @@ -118,7 +119,7 @@ fn sketch_json(s: &Session, id: u64) -> Option { CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), - CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::>(), "control": control, "degree": degree, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), + CurveKind::Spline { ref pts, control, degree, periodic } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}), }; if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) { @@ -214,7 +215,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result { .map(|b| { let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }; if let Some(o) = v.as_object_mut() { - o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name))); + o.insert("visible".into(), json!(s.body_visible(&b.name))); } v }) @@ -400,7 +401,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "too many face appearances")); } let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone()); - a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name }); + a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false, certificate: None }); } } for c in &comps { @@ -413,11 +414,27 @@ fn appearance(s: &mut Session, p: &Value) -> Result { if look.is_none() && !faces.is_empty() { let st = s.model.state(); let doc = s.doc_mut(); - let picked: Vec<(String, Option)> = - faces.iter().map(|(b, pt)| (b.clone(), st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)))).collect(); + let picked: Vec<(String, Option, Option)> = faces + .iter() + .map(|(b, pt)| { + ( + b.clone(), + st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)), + st_names.iter().find(|(name, p, _)| name == b && p.dist(*pt) < 1e-12).and_then(|(_, _, name)| name.clone()), + ) + }) + .collect(); doc.appearances.faces.retain(|f| { let idx = st.body(&f.body).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, f.point)); - !picked.iter().any(|(b, i)| *b == f.body && i.is_some() && *i == idx) + !picked.iter().any(|(b, i, name)| { + *b == f.body + && (f + .name + .as_ref() + .zip(name.as_ref()) + .is_some_and(|(a, b)| solvecraft_doc::naming::strip_piece(a) == solvecraft_doc::naming::strip_piece(b)) + || (!f.strict_name && i.is_some() && *i == idx)) + }) }); } Ok(json!({ @@ -495,7 +512,7 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result { let b = st.body(name).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?; out["body"] = json!({ "name": name, - "appearance": s.doc.body_look(&b.name, b.feature).map(|l| look_json(&l)), + "appearance": s.doc.model_body_look(b).map(|l| look_json(&l)), "color": s.doc.body_color(b), "faces": s.doc.face_colors(b).iter().map(|(i, l)| json!({"face": i, "appearance": look_json(l)})).collect::>(), }); diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index e51d28e8..6a857611 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -85,7 +85,7 @@ pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option { let st = s.model.state(); let mut best: Option<(f64, u64)> = None; for b in &st.bodies { - let comp = s.doc.body_component(&b.name, b.feature); + let comp = s.doc.model_body_component(b); let occs: Vec = if comp == 0 { vec![0] } else { s.doc.occurrences.iter().filter(|o| o.component == comp).map(|o| o.id).collect() }; for occ in occs { let t = if occ == 0 { solvecraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occ) }; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 71c06ec6..beddce9b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,8 @@ macro_rules! created_doc { mod browser; mod clipboard; mod component; +mod component_clone; +mod component_clone_assembly; mod construct; mod delete; mod edit; @@ -35,6 +37,7 @@ mod plastic; mod sheet; mod sketch; pub(crate) use sketch::created as sketch_created; +pub(crate) use sketch::author_path_for_rebind; mod sketch_3d; mod sketch_canvas; mod sketch_constraints; diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 96541ac7..33b6fed5 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", @@ -86,8 +89,12 @@ fn placed(s: &Session) -> Vec<(Vec, Body)> { if comp == 0 { 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) { + for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp && !b.body.is_mesh()) { + // 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.model_body_offset(b).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/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs new file mode 100644 index 00000000..515c9cb9 --- /dev/null +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -0,0 +1,426 @@ +// Exact occurrence workflows through the same commands used by the Browser. +use crate::Session; +use serde_json::{Value, json}; + +fn run(s: &mut Session, command: &str, p: Value) -> Value { + s.execute(command, &p).unwrap() +} +fn created(s: &mut Session, name: &str) -> (u64, u64) { + let c = run(s, "component.create", json!({"name": name})); + (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()) +} +fn near(value: &Value, expected: f64) { + assert!((value.as_f64().unwrap() - expected).abs() < 1e-6, "{value} != {expected}"); +} + +#[test] +fn instance_copy_uses_selected_source_transform() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Block"); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [30,0,0]})); + let third = run(&mut s, "occurrence.copy", json!({"occurrence": second, "translate": [5,0,0]}))["occurrence"].as_u64().unwrap(); + let placement = s.doc.occurrences.iter().find(|o| o.id == third).unwrap(); + assert!((placement.transform[3][0] - 55.0).abs() < 1e-6); + assert_eq!(s.doc.occurrences.iter().find(|o| o.id == first).unwrap().transform[3][0], 0.0); +} + +#[test] +fn instance_world_move_under_rotated_parent_uses_selected_path() { + let mut s = Session::default(); + let (_, parent) = created(&mut s, "Parent"); + run(&mut s, "occurrence.move", json!({"occurrence": parent, "axis": [0,0,1], "angle": "90 deg"})); + let (_, child) = created(&mut s, "Child"); + run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1})); + run(&mut s, "occurrence.move", json!({"occurrence": child, "path": [parent, child], "space": "world", "translate": [10,0,0]})); + let result = run(&mut s, "occurrence.properties", json!({"occurrence": child, "path": [parent,child]})); + near(&result["total"]["center_of_mass"][0], 9.5); + near(&result["total"]["center_of_mass"][1], 0.5); +} + +#[test] +fn instance_authoring_at_second_placement_keeps_definition_shared() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Block"); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component": component, "occurrence": second, "path": [second]})); + run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [20,0,0], "operation": "new"})); + for (occurrence, x) in [(first, 1.0), (second, 21.0)] { + let result = run(&mut s, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]})); + near(&result["total"]["center_of_mass"][0], x); + } +} + +#[test] +fn instance_selection_survives_rename_move_capture_and_serde() { + let mut s = Session::default(); + let (component, _) = created(&mut s, "Block"); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + let selected = json!({"type": "occurrence", "path": [second]}); + run(&mut s, "select.set", json!({"items": [selected]})); + run(&mut s, "component.rename", json!({"component": component, "name": "Renamed"})); + run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [5,0,0], "capture": false})); + run(&mut s, "component.capture_position", json!({})); + assert_eq!(serde_json::to_value(&s.selection).unwrap(), json!([selected])); + let roundtrip: Vec = serde_json::from_value(json!([selected])).unwrap(); + assert_eq!(roundtrip, s.selection); +} + +#[test] +fn instance_selection_delete_removes_only_selected_instance() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Block"); + run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2})); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"items": [{"type": "occurrence", "path": [second]}]})); + let items = s.selection.clone(); + run(&mut s, "selection.delete", json!({"items": items})); + assert!(s.doc.occurrences.iter().any(|o| o.id == first)); + assert!(!s.doc.occurrences.iter().any(|o| o.id == second)); + assert_eq!(s.world_state().bodies.len(), 1); + assert!(s.doc.components.iter().any(|c| c.id == component)); +} + +#[test] +fn world_move_includes_pending_parent_and_leaf_and_shared_scope() { + let mut s = Session::default(); + let (parent_component, parent) = created(&mut s, "Parent"); + let (_, child) = created(&mut s, "Child"); + run(&mut s, "occurrence.move", json!({"occurrence":parent,"axis":[0,0,1],"angle":"90 deg","translate":[3,0,0],"capture":false})); + run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[2,0,0],"capture":false})); + let before = s.occurrence_placement(&[parent, child]).unwrap().world; + run(&mut s, "occurrence.move", json!({"occurrence":child,"path":[parent,child],"space":"world","translate":[10,0,0],"capture":false})); + let after = s.occurrence_placement(&[parent, child]).unwrap().world; + assert!((after[3][0] - before[3][0] - 10.0).abs() < 1e-8); + assert!((after[3][1] - before[3][1]).abs() < 1e-8); + run( + &mut s, + "occurrence.move", + json!({"occurrence":child,"path":[parent,child],"space":"world","axis":[0,0,1],"angle":"90 deg","origin":[0,0,0],"capture":false}), + ); + let rotated = s.occurrence_placement(&[parent, child]).unwrap().world; + assert!((rotated[3][0] + after[3][1]).abs() < 1e-8); + assert!((rotated[3][1] - after[3][0]).abs() < 1e-8); + run(&mut s, "component.activate", json!({"component":parent_component})); + let second_parent = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[30,0,0]}))["occurrence"].as_u64().unwrap(); + assert_eq!(s.occurrence_scope_count(&[second_parent, child]).unwrap(), 2); + assert!(s.execute("occurrence.move", &json!({"occurrence":child,"space":"world","translate":[1,0,0]})).is_err()); +} + +#[test] +fn invalid_paths_are_atomic_for_session_state() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Part"); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); + run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]})); + let pending = s.pending_moves.clone(); + let active = s.active_occurrence_path.clone(); + let vis = s.visibility.clone(); + let doc = s.doc.clone(); + for (cmd, p) in [ + ("occurrence.move", json!({"occurrence":second,"path":[first],"space":"world","translate":[10,0,0]})), + ("occurrence.move", json!({"occurrence":second,"space":17,"translate":[10,0,0]})), + ("component.activate", json!({"component":component,"occurrence":second,"path":[first,second]})), + ("browser.visibility", json!({"visible":true,"paths":[[second],[9999]]})), + ("select.set", json!({"items":[{"type":"occurrence","path":[first,second]}]})), + ("occurrence.copy", json!({"occurrence":second,"path":[first]})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd}"); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.active_occurrence_path, active); + assert_eq!(s.visibility, vis); + assert_eq!(s.doc, doc); + } +} + +#[test] +fn exact_visibility_isolate_rename_and_async_accept() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + let first_body = s.occurrence_bodies(&[first], true).unwrap()[0].clone(); + let second_body = s.occurrence_bodies(&[second], true).unwrap()[0].clone(); + run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]})); + assert!(s.body_visible(&first_body)); + assert!(!s.body_visible(&second_body)); + let mut scratch = s.scratch(); + assert_eq!(scratch.visibility, s.visibility); + run(&mut scratch, "component.rename", json!({"component":component,"name":"Changed"})); + s.accept_computed(scratch); + assert!(!s.occurrence_visible(&[second])); + run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[second]],"isolate":true})); + assert!(!s.body_visible(&first_body)); + assert!(s.body_visible(&second_body)); + run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"})); + assert!(s.body_visible(&first_body)); + assert!(s.body_visible(&second_body)); +} + +#[test] +fn synthesized_name_collision_keeps_source_and_instance_separate() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let root = s.model.state().bodies[0].name.clone(); + let (_, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + run(&mut s, "body.rename", json!({"body":root,"name":candidate})); + let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + assert_ne!(instance, candidate); + let frames = s.doc.body_frames(&s.model.state()); + let world = s.world_state(); + assert!(frames.iter().all(|(name, _, _, _)| world.body(name).is_some())); + assert_eq!(world.bodies.len(), world.bodies.iter().map(|b| &b.name).collect::>().len()); + run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]})); + assert!(s.body_visible(&candidate)); + assert!(!s.body_visible(&instance)); + let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone(); + run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"})); + assert_eq!(s.doc.materials.get(&source).map(String::as_str), Some("Brass")); + assert!(!s.doc.materials.contains_key(&candidate)); +} + +#[test] +fn duplicate_nested_labels_use_full_path_and_reopen_undo_preserve_placements() { + let mut s = Session::default(); + let (parent_component, parent) = created(&mut s, "Parent"); + let (_, child) = created(&mut s, "Child"); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let parent2 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap(); + let parent3 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap(); + for o in s.doc_mut().occurrences.iter_mut().filter(|o| o.id == parent2 || o.id == parent3) { + o.name = "Same".into(); + } + s.refresh(); + let a = s.occurrence_bodies(&[parent2, child], false).unwrap()[0].clone(); + let b = s.occurrence_bodies(&[parent3, child], false).unwrap()[0].clone(); + assert_ne!(a, b); + let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap()); + assert_eq!(reopened.occurrence_bodies(&[parent2, child], false).unwrap()[0], a); + assert_eq!(reopened.occurrence_bodies(&[parent3, child], false).unwrap()[0], b); + run(&mut reopened, "selection.delete", json!({"occurrences":[parent3]})); + assert!(reopened.occurrence_placement(&[parent3, child]).is_err()); + run(&mut reopened, "edit.undo", json!({})); + assert!(reopened.occurrence_placement(&[parent3, child]).is_ok()); + assert!(reopened.occurrence_placement(&[parent, child]).is_ok()); +} + +#[test] +fn compact_duplicated_assembly_exact_resolution_and_expansion_limit() { + let mut s = Session::default(); + let mut parent = 0; + let mut path = Vec::new(); + for i in 0..18 { + let component = s.doc_mut().add_component(Some(&format!("Level{i}")), parent).unwrap(); + let occurrence = s.doc.occurrence_of(component).unwrap().id; + path.push(occurrence); + s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap(); + parent = component; + } + assert!(s.occurrence_placement(&path).is_ok()); + let top = [path[0]]; + assert!(s.occurrence_bodies(&top, true).is_err()); + assert!(s.execute("select.set", &json!({"items":[{"type":"occurrence","path":top}]})).is_err()); + assert!(s.selection.is_empty()); +} + +#[test] +fn identity_instances_map_world_keys_to_shared_sources() { + let mut s = Session::default(); + let (_, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap(); + let placement = s.occurrence_placement(&[second]).unwrap(); + let body = &placement.bodies[0]; + assert_ne!(body.source, body.world); + run(&mut s, "material.assign", json!({"bodies":[body.world],"material":"Brass"})); + assert_eq!(s.doc.materials.get(&body.source).map(String::as_str), Some("Brass")); +} + +#[test] +fn dialog_baseline_move_preserves_other_pending_positions_without_drift() { + let mut s = Session::default(); + let (_, first) = created(&mut s, "Part"); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[5,0,0],"capture":false})); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[3,0,0],"capture":false})); + let baseline = s.occurrence_placement(&[second]).unwrap().local; + for offset in [10.0, 15.0, 0.0] { + run( + &mut s, + "occurrence.move", + json!({"occurrence":second,"path":[second],"space":"world","base_transform":baseline,"translate":[offset,0,0],"capture":false}), + ); + assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0); + assert_eq!(s.pending_moves.get(&second).unwrap()[3][0], 23.0 + offset); + } + let pending = s.pending_moves.clone(); + assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,1]]})).is_err()); + assert_eq!(s.pending_moves, pending); + for base in [ + [[2.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], + [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], + ] { + assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":base})).is_err()); + assert_eq!(s.pending_moves, pending); + } + assert!(s.execute("component.revert_position", &json!({"occurrences":[second,9999]})).is_err()); + assert_eq!(s.pending_moves, pending); + run(&mut s, "component.revert_position", json!({"occurrences":[second]})); + assert!(!s.pending_moves.contains_key(&second)); + assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0); +} + +#[test] +fn hostile_moves_copies_and_reused_ids_do_not_leak_state() { + let mut s = Session::default(); + let (_, first) = created(&mut s, "Part"); + run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[10,0,0],"capture":false})); + let pending = s.pending_moves.clone(); + let doc = s.doc.clone(); + for (cmd, p) in [ + ("occurrence.move", json!({"occurrence":first,"origin":[1e308,0,0],"angle":"180 deg"})), + ("occurrence.move", json!({"occurrence":first,"translate":[1e9,0,0],"capture":false})), + ("occurrence.move", json!({"occurrence":first,"translate":[1,2]})), + ("occurrence.move", json!({"occurrence":first,"angle":false})), + ("occurrence.move", json!({"occurrence":first,"axis":[1e308,0,0],"angle":"90 deg"})), + ("occurrence.move", json!({"occurrence":first,"axis":[0,0,0],"angle":"90 deg"})), + ("occurrence.copy", json!({"occurrence":first,"translate":[1e9,0,0]})), + ("occurrence.copy", json!({"occurrence":first,"translate":"oops"})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd}"); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.doc, doc); + } + run(&mut s, "edit.undo", json!({})); + assert!(s.pending_moves.is_empty()); + let (_, other) = created(&mut s, "Other"); + assert_eq!(first, other); + assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0); + run(&mut s, "occurrence.move", json!({"occurrence":other,"translate":[20,0,0],"capture":false})); + run(&mut s, "component.capture_position", json!({})); + assert!(s.pending_moves.is_empty()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.pending_moves.get(&other).unwrap()[3][0], 20.0); + assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 0.0); + assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 20.0); + run(&mut s, "edit.redo", json!({})); + assert!(s.pending_moves.is_empty()); + assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 20.0); +} + +#[test] +fn isolate_nested_child_hides_ancestor_material_and_sibling() { + let mut s = Session::default(); + let (_, parent) = created(&mut s, "Parent"); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); + let (child_component, child) = created(&mut s, "Child"); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let parent_component = s.doc.occurrences.iter().find(|o| o.id == child).unwrap().parent; + let sibling = s + .doc_mut() + .add_occurrence( + child_component, + parent_component, + solvecraft_doc::rigid(crate::geom::Vec3::new(20.0, 0.0, 0.0), crate::geom::Vec3::ZERO, crate::geom::Vec3::Z, 0.0), + ) + .unwrap(); + s.refresh(); + run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[parent,child]],"isolate":true})); + for (path, expected) in [(vec![parent], false), (vec![parent, child], true), (vec![parent, sibling], false)] { + let body = s.occurrence_bodies(&path, false).unwrap()[0].clone(); + assert_eq!(s.body_visible(&body), expected); + } +} + +#[test] +fn context_new_child_uses_inactive_parent_placement() { + let mut s = Session::default(); + let (parent_component, parent) = created(&mut s, "Parent"); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":0})); + let child = + run(&mut s, "component.create", json!({"parent":parent_component,"parent_path":[second],"name":"Child"}))["occurrence"].as_u64().unwrap(); + assert_eq!(s.active_occurrence_path, Some(vec![second, child])); + assert_eq!(s.active_component_frame()[3][0], 20.0); + let doc = s.doc.clone(); + let active = s.active_occurrence_path.clone(); + assert!(s.execute("component.create", &json!({"parent":parent_component,"parent_path":[999],"name":"Invalid"})).is_err()); + assert_eq!(s.doc, doc); + assert_eq!(s.active_occurrence_path, active); +} + +#[test] +fn paste_new_uses_selected_pending_placement() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[30,0,0],"capture":false})); + let copied = + run(&mut s, "component.paste_new", json!({"component":component,"occurrence":second,"path":[second],"translate":[5,0,0]}))["component"] + .as_u64() + .unwrap(); + assert_eq!(s.doc.occurrence_of(copied).unwrap().transform[3][0], 55.0); + let result = run(&mut s, "component.properties", json!({"component":copied})); + near(&result["total"]["center_of_mass"][0], 55.5); + let result = run(&mut s, "occurrence.properties", json!({"occurrence":first,"path":[first]})); + near(&result["total"]["center_of_mass"][0], 0.5); +} + +#[test] +fn instance_styles_follow_source_identity_after_collision_rename_and_pending_move() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let root = s.model.state().bodies[0].name.clone(); + let (component, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + run(&mut s, "body.rename", json!({"body":root,"name":candidate})); + let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone(); + run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"})); + let world = s.world_state(); + assert_eq!(s.doc.body_color(world.body(&instance).unwrap()), s.doc.body_color(world.body(&source).unwrap())); + run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"color":"#0000ff","opacity":0.7})); + assert_eq!(s.doc.body_color(s.world_state().body(&instance).unwrap()), Some([0.0, 0.0, 1.0])); + run(&mut s, "appearance.assign", json!({"bodies":[instance],"color":"#ff0000","opacity":0.5})); + run(&mut s, "appearance.assign", json!({"faces":[{"body":instance,"point":[21,1,2]}],"color":"#00ff00","opacity":0.4})); + // Exercise the point-based face fallback as well as persistent face-name matching. + for face in &mut s.doc_mut().appearances.faces { + face.name = None; + } + run(&mut s, "body.rename", json!({"body":source,"name":"Shared Renamed"})); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); + let world = s.world_state(); + for id in [first, second] { + let bodyname = s.occurrence_bodies(&[id], false).unwrap()[0].clone(); + let body = world.body(&bodyname).unwrap(); + assert_eq!(body.definition_name(), "Shared Renamed"); + assert_eq!(body.component, component); + let look = s.doc.model_body_look(body).unwrap(); + assert_eq!(look.color, [255, 0, 0]); + assert_eq!(look.opacity, 0.5); + assert!(s.doc.face_colors(body).iter().any(|(_, l)| l.color == [0, 255, 0] && l.opacity == 0.4)); + let measured = run(&mut s, "inspect.measure", json!({"bodies":[bodyname]})); + assert_eq!(measured["bodies"][0]["material"], "Brass"); + assert!((measured["bodies"][0]["mass_g"].as_f64().unwrap() - 0.068).abs() < 1e-8); + } + let definition = s.model.state(); + assert_eq!(solvecraft_kernel::measure(&definition.body("Shared Renamed").unwrap().body).unwrap().bbox.min, crate::geom::Vec3::ZERO); + let painted = s.doc.painted(&world); + let secondname = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + let body = painted.body(&secondname).unwrap(); + assert_eq!(body.body.color(), Some([1.0, 0.0, 0.0])); + assert!(body.body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0])); + let bytes = solvecraft_io::export(&painted, &[secondname], solvecraft_io::Format::Step, "InstanceStyles").unwrap(); + let imported = solvecraft_kernel::step_import(&String::from_utf8(bytes).unwrap()).unwrap(); + assert_eq!(imported.bodies.len(), 1); + assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0])); + assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0])); +} diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index 0a5d4401..92cc7529 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -464,7 +464,7 @@ mod tests { let st = s.world_state(); let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0); let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN)); - for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) { + for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp) { let m = b.mesh().bounds(); lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z)); hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z)); diff --git a/crates/engine/src/cmd/revolve_convention_tests.rs b/crates/engine/src/cmd/revolve_convention_tests.rs new file mode 100644 index 00000000..55cf400c --- /dev/null +++ b/crates/engine/src/cmd/revolve_convention_tests.rs @@ -0,0 +1,309 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_doc::{Document, FeatureKind, RevolveConvention}; +use solvecraft_geom::Vec3; +use std::f64::consts::{FRAC_PI_2, PI, SQRT_2}; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn profile(s: &mut Session, opposed: bool) { + run(s, "sketch.create", json!({"name":"Profile","plane":"XY"})); + run(s, "sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]})); + if opposed { + run(s, "sketch.rectangle.two_point", json!({"p0":[-4,0],"p1":[-2,10]})); + } + run(s, "sketch.finish", json!({})); +} + +fn turn(s: &mut Session, sign: f64, settings: Value) { + let mut p = json!({"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,sign,0]},"angle":"90 deg","direction":"symmetric","operation":"new","name":"Turn","body_name":"Turned"}); + p.as_object_mut().unwrap().extend(settings.as_object().unwrap().clone()); + run(s, "solid.revolve", p); +} + +fn check_centered(s: &Session, opposed: bool) { + let state = s.model.state(); + let expected = 30.0 * PI * if opposed { 2.0 } else { 1.0 }; + let volume: f64 = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).sum(); + assert!((volume - expected).abs() < 0.03, "{volume} vs {expected}"); + for sign in if opposed { vec![1.0, -1.0] } else { vec![1.0] } { + let body = &state.bodies.iter().find(|mb| mb.body.contains(Vec3::new(sign * 3.0, 5.0, 0.0))).unwrap().body; + assert!(!body.contains(Vec3::new(0.0, 5.0, 3.0))); + assert!(body.contains(Vec3::new(sign * 3.0 * (PI / 6.0).cos(), 5.0, 1.5))); + assert!(body.contains(Vec3::new(sign * 3.0 * (PI / 6.0).cos(), 5.0, -1.5))); + let bounds = body.tessellate(1e-4).unwrap().bounds(); + assert!((bounds.min.z + 2.0 * SQRT_2).abs() < 1e-6, "{bounds:?}"); + assert!((bounds.max.z - 2.0 * SQRT_2).abs() < 1e-6, "{bounds:?}"); + let inner_x = if sign > 0.0 { bounds.min.x } else { -bounds.max.x }; + assert!((inner_x - SQRT_2).abs() < 1e-6, "{bounds:?}"); + let centroid = solvecraft_kernel::measure(body).unwrap().centroid; + let radial = (2.0 / 3.0) * (64.0 - 8.0) / (16.0 - 4.0); + let want_x = radial * (PI / 4.0).sin() / (PI / 4.0); + assert!((centroid.x - sign * want_x).abs() < 0.005, "{centroid:?}"); + assert!((centroid.y - 5.0).abs() < 1e-6); + assert!(centroid.z.abs() < 1e-6); + } +} + +#[test] +fn new_symmetric_revolves_center_material_for_both_axis_signs_and_negative_angles() { + for sign in [-1.0, 1.0] { + for angle in ["90 deg", "-90 deg"] { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, sign, json!({"angle":angle})); + assert!(matches!(s.doc.find_feature("Turn").unwrap().kind, FeatureKind::Revolve { convention: RevolveConvention::SketchNormal, .. })); + check_centered(&s, false); + } + } +} + +#[test] +fn opposed_profiles_use_independent_sweep_axes() { + for sign in [-1.0, 1.0] { + let mut s = Session::default(); + profile(&mut s, true); + turn(&mut s, sign, json!({})); + check_centered(&s, true); + } +} + +#[test] +fn two_sided_revolves_use_the_canonical_negative_and_positive_limits() { + for sign in [-1.0, 1.0] { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, sign, json!({"direction":"positive","angle":"60 deg","angle2":"30 deg"})); + let state = s.model.state(); + let body = &state.bodies[0].body; + let bounds = body.tessellate(1e-4).unwrap().bounds(); + assert!((bounds.min.x - 1.0).abs() < 1e-6, "{bounds:?}"); + assert!((bounds.max.x - 4.0).abs() < 1e-4); + assert!((bounds.min.z + 2.0).abs() < 1e-6); + assert!((bounds.max.z - 2.0 * 3.0f64.sqrt()).abs() < 1e-6); + assert!(body.contains(Vec3::new(3.0, 5.0, 0.0))); + assert!(!body.contains(Vec3::new(0.0, 5.0, 3.0))); + assert!((solvecraft_kernel::measure(body).unwrap().volume - 30.0 * PI).abs() < 0.03); + } +} + +#[test] +fn legacy_missing_convention_keeps_geometry_through_edit_redefine_undo_and_reopen() { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, -1.0, json!({"convention":"legacy_axis_start"})); + let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + saved["format"] = json!("solvecraft/10"); + for f in saved["features"].as_array_mut().unwrap() { + f.as_object_mut().unwrap().remove("convention"); + } + let mut s = Session::new(Document::from_json(&saved.to_string()).unwrap()); + let original = s.doc.find_feature("Turn").unwrap().kind.clone(); + let check = |s: &Session| { + assert_eq!(s.doc.find_feature("Turn").unwrap().kind, original); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(body.contains(Vec3::new(0.0, 5.0, 3.0))); + assert!(!body.contains(Vec3::new(3.0, 5.0, 0.0))); + let bounds = body.tessellate(1e-4).unwrap().bounds(); + assert!((bounds.min.x + 2.0 * SQRT_2).abs() < 1e-6); + assert!((bounds.max.x - 2.0 * SQRT_2).abs() < 1e-6); + assert!((bounds.min.z - SQRT_2).abs() < 1e-6); + }; + check(&s); + let angle_input = s.doc.find_feature("Turn").unwrap().param_names[0].clone(); + run(&mut s, "parameters.change", json!({"name":angle_input,"expression":"60 deg"})); + let changed = s.model.state(); + assert!(changed.bodies[0].body.contains(Vec3::new(1.5, 5.0, 3.0 * 3.0f64.sqrt() / 2.0))); + assert!(!changed.bodies[0].body.contains(Vec3::new(3.0, 5.0, 0.0))); + run(&mut s, "edit.undo", json!({})); + check(&s); + run(&mut s, "timeline.edit", json!({"feature":"Turn","set":{"angle":"90 deg"}})); + check(&s); + run( + &mut s, + "timeline.redefine", + json!({"feature":"Turn","command":"solid.revolve","params":{"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,-1,0]},"angle":"90 deg","direction":"symmetric","body_name":"Turned"}}), + ); + check(&s); + run(&mut s, "timeline.edit", json!({"feature":"Turn","set":{"convention":"sketch_normal"}})); + check_centered(&s, false); + run(&mut s, "edit.undo", json!({})); + check(&s); + let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap()); + check(&reopened); + // A newly authored feature in this migrated document uses current semantics. + turn(&mut s, -1.0, json!({"name":"NewTurn","body_name":"NewTurned"})); + assert!(matches!(s.doc.find_feature("NewTurn").unwrap().kind, FeatureKind::Revolve { convention: RevolveConvention::SketchNormal, .. })); +} + +#[test] +fn extent_handle_angles_match_new_and_legacy_material() { + let mut s = Session::default(); + profile(&mut s, false); + let state = s.model.state(); + let ss = &state.sketches[0]; + let regions = vec![ss.profiles[0].region.clone()]; + for convention in [RevolveConvention::SketchNormal, RevolveConvention::LegacyAxisStart] { + let extent = solvecraft_doc::revolve_extent_with_convention( + &state, + &ss.plane, + ®ions, + (Vec3::ZERO, -Vec3::Y), + (FRAC_PI_2, None), + true, + None, + convention, + ) + .unwrap(); + assert_eq!(extent.rotation_axis, -Vec3::Y); + assert!((extent.display_start - if convention == RevolveConvention::SketchNormal { -PI / 4.0 } else { PI / 4.0 }).abs() < 1e-12); + assert!((extent.sweep - FRAC_PI_2).abs() < 1e-12); + } +} + +#[test] +fn symmetric_revolve_preserves_a_translated_rotated_sketch_frame() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"name":"Profile","plane":{"origin":[10,20,30],"x_dir":[0,0,1],"y_dir":[1,0,0]}})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":{"origin":[10,20,30],"dir":[-1,0,0]},"angle":"90 deg","direction":"symmetric"})); + let state = s.model.state(); + let body = &state.bodies[0].body; + let bounds = body.tessellate(1e-4).unwrap().bounds(); + assert!(bounds.min.dist(Vec3::new(10.0, 20.0 - 2.0 * SQRT_2, 30.0 + SQRT_2)) < 1e-6, "{bounds:?}"); + assert!(bounds.max.dist(Vec3::new(20.0, 20.0 + 2.0 * SQRT_2, 34.0)) < 1e-4, "{bounds:?}"); + assert!(body.contains(Vec3::new(15.0, 20.0, 33.0))); + assert!(!body.contains(Vec3::new(15.0, 23.0, 30.0))); + assert!((solvecraft_kernel::measure(body).unwrap().volume - 30.0 * PI).abs() < 0.03); +} + +#[test] +fn copied_revolves_keep_their_convention_and_angular_material() { + for convention in ["sketch_normal", "legacy_axis_start"] { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, -1.0, json!({"convention":convention})); + run(&mut s, "timeline.copy", json!({"features":["Profile","Turn"]})); + run(&mut s, "timeline.paste", json!({"translate":[0,20,0]})); + let state = s.model.state(); + let probe = if convention == "sketch_normal" { Vec3::new(3.0, 25.0, 0.0) } else { Vec3::new(0.0, 25.0, 3.0) }; + assert!(state.bodies.iter().any(|mb| mb.body.contains(probe))); + let modes: Vec<_> = s + .doc + .features + .iter() + .filter_map(|f| if let FeatureKind::Revolve { convention, .. } = f.kind { Some(convention) } else { None }) + .collect(); + assert_eq!(modes.len(), 2); + assert_eq!(modes[0], modes[1]); + let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.state().bodies.iter().any(|mb| mb.body.contains(probe))); + } +} + +#[test] +fn centered_coplanar_cut_uses_the_actual_angular_interval() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner":[2,0,-4],"length":2,"width":10,"height":8,"body_name":"Stock"})); + profile(&mut s, false); + turn(&mut s, -1.0, json!({"operation":"cut","targets":["Stock"]})); + let state = s.model.state(); + // Removed sector clipped by x>=2: 10∫[-π/4,π/4](16−4sec²θ)/2 dθ. + // The sector crosses the whole stock at z=0, leaving two disconnected solids. + let expected = 200.0 - 40.0 * PI; + let volumes: Vec<_> = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).collect(); + let total: f64 = volumes.iter().sum(); + assert!((total - expected).abs() < 0.05, "{volumes:?}: total{total}, expected{expected}"); + assert_eq!(state.bodies.len(), 2); + assert!(volumes.iter().all(|v| (*v - expected / 2.0).abs() < 0.025)); + let contains = |p| state.bodies.iter().any(|mb| mb.body.contains(p)); + assert!(!contains(Vec3::new(3.0, 5.0, 0.0))); + assert!(contains(Vec3::new(3.0, 5.0, 3.5))); + assert!(contains(Vec3::new(3.0, 5.0, -3.5))); +} + +#[test] +fn one_sided_and_full_revolves_keep_the_existing_material() { + for angle in ["90 deg", "360 deg"] { + for sign in [-1.0, 1.0] { + for convention in ["legacy_axis_start", "sketch_normal"] { + let mut s = Session::default(); + profile(&mut s, false); + turn(&mut s, sign, json!({"angle":angle,"direction":"positive","convention":convention})); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(body.contains(Vec3::new(3.0 / SQRT_2, 5.0, 3.0 / SQRT_2))); + assert_eq!(body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, -3.0 / SQRT_2)), angle == "360 deg"); + let expected = if angle == "360 deg" { 120.0 * PI } else { 30.0 * PI }; + assert!((solvecraft_kernel::measure(body).unwrap().volume - expected).abs() < 0.08); + } + } + } +} + +#[test] +fn point_targets_use_each_profiles_canonical_axis_for_both_axis_signs() { + for sign in [-1.0, 1.0] { + for opposed in [false, true] { + let mut s = Session::default(); + profile(&mut s, opposed); + turn(&mut s, sign, json!({"to":[0,5,3]})); + let state = s.model.state(); + let expected = 30.0 * PI * if opposed { 2.0 } else { 1.0 }; + let volume: f64 = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).sum(); + assert!((volume - expected).abs() < 0.05, "{volume}"); + assert!(state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(3.0 / SQRT_2, 5.0, 3.0 / SQRT_2)))); + assert!(!state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(3.0 / SQRT_2, 5.0, -3.0 / SQRT_2)))); + assert_eq!(state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, 3.0 / SQRT_2))), opposed); + assert!(!state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, -3.0 / SQRT_2)))); + } + } +} + +#[test] +fn an_axis_target_is_an_actionable_atomic_error_for_new_convention() { + let mut s = Session::default(); + profile(&mut s, false); + let before = s.doc.to_json(); + let e = s.execute("solid.revolve", &json!({"sketch":"Profile","axis":"Y","to":[0,5,0]})).unwrap_err().to_string(); + assert!(e.contains("target lies on the axis"), "{e}"); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn curved_targets_keep_only_the_material_before_the_post_for_both_axis_signs() { + for sign in [-1.0, 1.0] { + for opposed in [false, true] { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"base":[0,14,-5],"radius":4,"height":15,"body_name":"Post"})); + run(&mut s, "sketch.create", json!({"name":"Profile","plane":"XZ"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})); + if opposed { + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-15,0],"p1":[-10,5]})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,0,sign]},"to":[4,14,2],"body_name":"Arc"})); + let state = s.model.state(); + let post = &state.body("Post").unwrap().body; + let arcs: Vec<_> = state.bodies.iter().filter(|mb| mb.name != "Post").collect(); + assert_eq!(arcs.len(), if opposed { 2 } else { 1 }); + for side in if opposed { vec![1.0, -1.0] } else { vec![1.0] } { + let probe = Vec3::new(side * 12.0 * (PI / 6.0).cos(), 6.0, -2.5); + let arc = &arcs.iter().find(|mb| mb.body.contains(probe)).unwrap().body; + assert!(!arc.contains(Vec3::new(-side * 12.0 * (PI / 6.0).cos(), 6.0, -2.5))); + assert!(!arc.contains(Vec3::new(side * 12.0 * (PI / 6.0).cos(), -6.0, -2.5))); + let full = PI * (225.0 - 100.0) * 5.0; + let angle = solvecraft_kernel::measure(arc).unwrap().volume / full * 360.0; + assert!(angle > 60.0 && angle < 90.0, "{angle}"); + let overlap = solvecraft_kernel::boolean(arc, post, solvecraft_kernel::BoolOp::Intersect).unwrap(); + assert!(overlap.is_none_or(|b| solvecraft_kernel::measure(&b).unwrap().volume < 1e-3)); + } + } + } +} diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index bbaf5333..443a0a51 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 => { @@ -270,11 +274,17 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result { } fn flat_json(sh: &SheetBody) -> Result { - let (w, h) = sh.flat_size()?; - let loops = sh.outline()?; + let regions = sh.flat_regions()?; + let loops: Vec> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect(); + let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); + for p in loops.iter().flatten() { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + let (w, h) = (hi.x - lo.x, hi.y - lo.y); let pt = |v: &Vec2| json!([v.x, v.y]); let outline: Vec = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect(); - let holes: Vec = sh.holes.iter().map(|h| Value::Array(h.outer.polyline(1e-3).iter().map(pt).collect())).collect(); + let holes: Vec = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect(); let bends: Vec = sh .bends() .iter() @@ -283,8 +293,24 @@ fn flat_json(sh: &SheetBody) -> Result { "allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)}) }) .collect(); + let pockets: Vec = sh + .machining()? + .iter() + .map(|p| { + let side = if p.z0 == 0.0 { + "bottom" + } else if p.z1 == sh.t { + "top" + } else { + "internal" + }; + json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::>(), + "cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::>()).collect::>(), + "side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1}) + }) + .collect(); Ok( - json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "bends": bends, "unfolded": sh.flat}), + json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}), ) } @@ -295,6 +321,22 @@ fn flat_pattern(s: &mut Session, p: &Value) -> Result { /// A DXF (R12 entities, mm) of the flat pattern on named layers. pub fn flat_dxf(sh: &SheetBody) -> Result { + let regions = sh.flat_regions()?; + let pockets = sh.machining()?; + let pocket_layer = |p: &solvecraft_doc::sheet::SheetPocket| { + let side = if p.z0 == 0.0 { + "BOTTOM" + } else if p.z1 == sh.t { + "TOP" + } else { + "INTERNAL" + }; + format!("POCKET_{side}_Z{:.6}_TO_{:.6}_MM", p.z0, p.z1) + }; + let mut layers = std::collections::BTreeSet::from(["OUTLINE".to_string(), "BEND_UP".to_string(), "BEND_DOWN".to_string()]); + for p in &pockets { + layers.insert(pocket_layer(p)); + } let mut out = String::new(); let mut w = |c: i32, v: &str| out.push_str(&format!("{c}\n{v}\n")); w(0, "SECTION"); @@ -306,7 +348,14 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { w(2, "TABLES"); w(0, "TABLE"); w(2, "LAYER"); - for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] { + w(70, &layers.len().to_string()); + for name in &layers { + let colour = match name.as_str() { + "OUTLINE" => "7", + "BEND_UP" => "1", + "BEND_DOWN" => "5", + _ => "3", + }; w(0, "LAYER"); w(2, name); w(70, "0"); @@ -326,31 +375,51 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { w(11, &f(b.x)); w(21, &f(b.y)); }; - for l in sh.outline()? { - let m = l.len(); - for i in 0..m { - if let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % m)) { - line(&mut w, "OUTLINE", *a, *b); - } + let mut loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|lp| (lp, "OUTLINE".to_string())).collect(); + for p in &pockets { + for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) { + loops.push((lp, pocket_layer(p))); } } - for h in &sh.holes { - for seg in &h.outer.segs { + for (h, layer) in loops { + // A circle is often represented by two semicircles. Keep the whole analytic + // boundary as one CIRCLE rather than approximating outer boundaries with chords. + if let Some(solvecraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() { + let total: f64 = h.segs.iter().filter_map(|s| if let solvecraft_geom::Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum(); + let circular = h.segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum())); + if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 { + w(0, "CIRCLE"); + w(8, &layer); + w(10, &f(center.x)); + w(20, &f(center.y)); + w(40, &f(*radius)); + continue; + } + } + for seg in &h.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, &layer); + 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(8, &layer); 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]); + line(&mut w, &layer, p[0], p[1]); } } } @@ -375,6 +444,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..0c5fd39e 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -31,6 +31,363 @@ fn base(s: &mut Session, w: f64, h: f64) { run(s, "sheet.flange", json!({"sketch": "Base"})); } +fn rectangle_tool(s: &mut Session, name: &str, p0: [f64; 2], p1: [f64; 2]) { + run(s, "sketch.create", json!({"plane":"XY","name":name})); + run(s, "sketch.rectangle.two_point", json!({"p0":p0,"p1":p1})); + run(s, "sketch.finish", json!({})); +} + +#[test] +fn shallow_sheet_pocket_preserves_material_depth_export_undo_and_reopen() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); + assert!((volume(&mut s) - 14900.0).abs() < 1e-6); + let f = flat(&mut s); + assert_eq!(f["cutouts"], json!([])); + assert_eq!(f["pockets"][0]["side"], "bottom"); + assert_eq!(f["pockets"][0]["depth_mm"], 1.0); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert!(dxf.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM")); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8); + run(&mut s, "edit.undo", json!({})); + assert!((volume(&mut s) - 15000.0).abs() < 1e-6); + run(&mut s, "edit.redo", json!({})); + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({})); + assert!((volume(&mut s) - 14900.0).abs() < 1e-6); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(!body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 0.5))); + assert!(body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 1.5))); + } + let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((volume(&mut reopened) - 14900.0).abs() < 1e-6); + assert_eq!(flat(&mut reopened)["pockets"], f["pockets"]); +} + +#[test] +fn planar_sheet_intersection_retains_exact_outline_and_islands() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Retain", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Retain","through_all":true,"direction":"symmetric","operation":"intersect"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!((volume(&mut s) - 250.0).abs() < 1e-6); + assert_eq!(flat(&mut s)["flat_size_mm"], json!([10.0, 10.0, 2.5])); + } + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 4); + let mut imported = Session::default(); + run(&mut imported, "sketch.insert_dxf", json!({"name":"Retained","text":dxf})); + run(&mut imported, "sketch.finish", json!({})); + run(&mut imported, "solid.extrude", json!({"sketch":"Retained","distance":2.5})); + assert!((volume(&mut imported) - 250.0).abs() < 1e-6); + // An annular retained region keeps its central hole, with no first-solid shortcut. + let mut annulus = Session::default(); + base(&mut annulus, 100.0, 60.0); + run(&mut annulus, "sketch.create", json!({"plane":"XY","name":"Ring"})); + run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":10})); + run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":5})); + run(&mut annulus, "sketch.finish", json!({})); + run( + &mut annulus, + "solid.extrude", + json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"intersect"}), + ); + run(&mut annulus, "sheet.unfold", json!({})); + let area: f64 = annulus.model.state().sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum(); + assert!((area - PI * 75.0).abs() < 1e-8, "exact retained area: {area}"); + // inspect.measure integrates a tessellation; exact arc area is checked above. + let measured = volume(&mut annulus); + assert!((measured - PI * 75.0 * 2.5).abs() < 0.1, "measured={measured}"); + assert_eq!(flat(&mut annulus)["cutouts"].as_array().unwrap().len(), 1); +} + +#[test] +fn overlapping_opposite_side_pockets_become_true_through_material() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Bottom", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Bottom","distance":1.5,"operation":"cut"})); + rectangle_tool(&mut s, "Top", [25.0, 20.0], [35.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Top","start_offset":2.5,"direction":"negative","distance":1.5,"operation":"cut"})); + let expected = 15000.0 - 100.0 * 1.5 * 2.0 + 50.0 * 0.5; + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!((volume(&mut s) - expected).abs() < 1e-6); + assert_eq!(flat(&mut s)["cutouts"].as_array().unwrap().len(), 1); + assert_eq!(flat(&mut s)["pockets"].as_array().unwrap().len(), 2); + } +} + +#[test] +fn pockets_survive_forming_away_from_bends_and_refuse_bend_overlap_atomically() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); + run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20})); + assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2); + run(&mut s, "sheet.unfold", json!({})); + let unfolded = volume(&mut s); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2); + assert!(unfolded > 14900.0); + let mut cross = Session::default(); + base(&mut cross, 100.0, 60.0); + rectangle_tool(&mut cross, "Pocket", [45.0, 20.0], [55.0, 30.0]); + run(&mut cross, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); + let before = cross.doc.to_json(); + let undo = cross.undo.len(); + let error = cross.execute("sheet.fold", &json!({"points":[[50,0,2.5],[50,60,2.5]]})).unwrap_err().to_string(); + assert!(error.contains("pocket") && error.contains("bend"), "{error}"); + assert_eq!(cross.doc.to_json(), before); + assert_eq!(cross.undo.len(), undo); + assert!((volume(&mut cross) - 14900.0).abs() < 1e-6); +} + +#[test] +fn unsupported_sheet_machining_is_atomic_but_true_no_change_is_allowed() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Taper", [20.0, 20.0], [30.0, 30.0]); + let before = s.doc.to_json(); + let undo = s.undo.len(); + let error = s.execute("solid.extrude", &json!({"sketch":"Taper","distance":1,"operation":"cut","taper":"5 deg"})).unwrap_err().to_string(); + assert!(error.contains("sheet") && error.contains("preserv"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), undo); + rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Outside","distance":1,"operation":"cut","taper":"5 deg"})); + assert!((volume(&mut s) - 15000.0).abs() < 1e-6); + rectangle_tool(&mut s, "All", [-20.0, -20.0], [120.0, 80.0]); + run(&mut s, "solid.extrude", json!({"sketch":"All","through_all":true,"direction":"symmetric","operation":"intersect"})); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 15000.0).abs() < 1e-6); +} + +#[test] +fn empty_sheet_intersection_removes_geometry_and_metadata_together() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"})); + assert!(s.model.state().bodies.is_empty()); + assert!(s.model.state().sheets.is_empty()); + assert!(s.execute("sheet.unfold", &json!({})).is_err()); + run(&mut s, "edit.undo", json!({})); + assert!((volume(&mut s) - 15000.0).abs() < 1e-6); +} + +#[test] +fn intersect_preserves_both_material_regions_of_a_logical_ring_sheet() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"Ring"})); + for radius in [10.0, 5.0] { + run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":radius})); + } + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"cut"}), + ); + rectangle_tool(&mut s, "Crop", [35.0, 15.0], [65.0, 45.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Crop","through_all":true,"direction":"symmetric","operation":"intersect"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + let state = s.model.state(); + let regions = state.sheets[0].flat_regions().unwrap(); + assert_eq!(regions.len(), 2); + let area: f64 = regions.iter().map(|r| r.area()).sum(); + assert!((area - (900.0 - PI * 75.0)).abs() < 1e-8, "area={area}"); + let body = &state.bodies[0].body; + assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.0))); + assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.0))); + assert!(body.contains(solvecraft_geom::Vec3::new(64.0, 30.0, 1.0))); + assert!(!body.contains(solvecraft_geom::Vec3::new(70.0, 30.0, 1.0))); + assert!((volume(&mut s) - (900.0 - PI * 75.0) * 2.5).abs() < 0.2); + } + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 6); +} + +#[test] +fn machining_a_folded_parallel_panel_preserves_its_pocket_on_unfold() { + use solvecraft_doc::{apply_point, apply_vector}; + use solvecraft_geom::Vec3; + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20})); + let sh = s.model.state().sheets[0].clone(); + let points = sh.panel(&sh.flanges[0]).outer.polyline(1e-3); + let c = points.iter().copied().fold(solvecraft_geom::Vec2::ZERO, |sum, p| sum + p) / (points.len() as f64); + let w = sh.world(Some(0)); + let origin = apply_point(&w, Vec3::ZERO); + let x = apply_vector(&w, Vec3::X); + let y = apply_vector(&w, Vec3::Y); + run( + &mut s, + "sketch.create", + json!({"name":"PanelPocket","plane":{"origin":[origin.x,origin.y,origin.z],"x_dir":[x.x,x.y,x.z],"y_dir":[y.x,y.y,y.z]}}), + ); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[c.x-2.0,c.y-2.0],"p1":[c.x+2.0,c.y+2.0]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":"PanelPocket","distance":1,"operation":"cut"})); + assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2); + run(&mut s, "sheet.unfold", json!({})); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(!body.contains(Vec3::new(c.x, c.y, 0.5))); + assert!(body.contains(Vec3::new(c.x, c.y, 1.5))); + assert_eq!(flat(&mut s)["pockets"][0]["depth_mm"], 1.0); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2); +} + +#[test] +fn flipped_sheet_pocket_keeps_depth_in_the_local_thickness_direction() { + let mut s = Session::default(); + rectangle_tool(&mut s, "Base", [0.0, 0.0], [100.0, 60.0]); + run(&mut s, "sheet.flange", json!({"sketch":"Base","flip":true})); + rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"direction":"negative","operation":"cut"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!((volume(&mut s) - 14900.0).abs() < 1e-6); + let state = s.model.state(); + assert!(!state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -0.5))); + assert!(state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -1.5))); + } +} + +#[test] +fn multiple_profile_intersect_keeps_tool_union_for_solids_and_sheet_groups() { + for sheet in [false, true] { + let mut s = Session::default(); + if sheet { + base(&mut s, 100.0, 60.0); + } else { + run(&mut s, "solid.box", json!({"length":100,"width":60,"height":2.5,"body_name":"Blank"})); + } + let target = s.model.state().bodies[0].name.clone(); + run(&mut s, "solid.box", json!({"corner":[200,0,0],"length":10,"width":10,"height":10,"body_name":"Control"})); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"TwoIslands"})); + for x in [20.0, 60.0] { + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[x,20],"p1":[x+10.0,30]})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":"TwoIslands","operation":"intersect","distance":3,"targets":[target]})); + assert!((volume(&mut s) - 1500.0).abs() < 1e-6); + if sheet { + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({"body":target})); + assert!((volume(&mut s) - 1500.0).abs() < 1e-6); + assert_eq!(flat(&mut s)["outline"].as_array().unwrap().len(), 2); + } + } + let state = s.model.state(); + for x in [25.0, 65.0] { + assert!(state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(x, 25.0, 1.0)))); + } + assert!(!state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(50.0, 25.0, 1.0)))); + assert!(state.bodies.iter().any(|b| b.name == "Control" && b.body.contains(solvecraft_geom::Vec3::new(205.0, 5.0, 5.0)))); + } +} + +#[test] +fn deleting_sheet_material_prunes_cosmetic_threads_and_undo_restores_them() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"ThreadPlate"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]})); + run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch":"ThreadPlate","profiles":[{"point":[10,10]}]})); + run(&mut s, "solid.thread", json!({"face":[55,30,1],"designation":"M10"})); + assert_eq!(s.model.state().threads.len(), 1); + rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"})); + assert!(s.model.state().bodies.is_empty()); + assert!(s.model.state().sheets.is_empty()); + assert!(s.model.state().threads.is_empty()); + assert!(solvecraft_doc::world_state(&s.doc, &s.model.state()).threads.is_empty()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.model.state().threads.len(), 1); +} + +#[test] +fn sheet_pocket_transfer_keeps_local_depth_world_pose_and_history() { + use solvecraft_geom::Vec3; + fn check(s: &Session, name: &str, owner: u64, local_origin: Vec3, local_y: Vec3, transferred: bool) { + let state = s.model.state(); + let mb = state.body(name).unwrap(); + assert_eq!(mb.component, owner); + assert_eq!(mb.transferred, transferred); + let sh = state.sheets.iter().find(|sh| sh.body == name).unwrap(); + assert!(sh.frame.origin.dist(local_origin) < 1e-8, "{:?}", sh.frame); + assert!(sh.frame.x.dist(Vec3::X) < 1e-8); + assert!(sh.frame.y.dist(local_y) < 1e-8); + assert_eq!(sh.pockets.len(), 1); + assert_eq!((sh.pockets[0].z0, sh.pockets[0].z1), (0.0, 1.0)); + let cut = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 0.5; + let floor = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 1.5; + assert!(!mb.body.contains(cut)); + assert!(mb.body.contains(floor)); + let world = s.world_state(); + let body = &world.body(name).unwrap().body; + let bounds = body.tessellate(1e-3).unwrap().bounds(); + assert!(bounds.min.dist(Vec3::ZERO) < 1e-6, "{bounds:?}"); + assert!(bounds.max.dist(Vec3::new(100.0, 60.0, 2.5)) < 1e-6, "{bounds:?}"); + assert!(!body.contains(Vec3::new(25.0, 25.0, 0.5))); + assert!(body.contains(Vec3::new(25.0, 25.0, 1.5))); + assert!((solvecraft_kernel::measure(body).unwrap().volume - 14900.0).abs() < 1e-6); + } + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); + let name = s.model.state().sheets[0].body.clone(); + check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false); + let destination = run(&mut s, "component.create", json!({"name":"Machined","parent":0})); + let component = destination["component"].as_u64().unwrap(); + let occurrence = destination["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true})); + run(&mut s, "component.move_bodies", json!({"component":component,"bodies":[name]})); + assert!(!s.doc.body_offsets.contains_key(&name), "recorded transfer must not leave a legacy placement offset"); + let origin = Vec3::new(-30.0, -10.0, 20.0); + let y = Vec3::new(0.0, 0.0, -1.0); + check(&s, &name, component, origin, y, true); + run(&mut s, "edit.undo", json!({})); + check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false); + run(&mut s, "edit.redo", json!({})); + check(&s, &name, component, origin, y, true); + run(&mut s, "component.activate", json!({"component":component})); + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({"body":name})); + check(&s, &name, component, origin, y, true); + let f = run(&mut s, "sheet.flat_pattern", json!({"body":name})); + assert_eq!(f["pockets"][0]["side"], "bottom"); + assert_eq!(f["pockets"][0]["depth_mm"], 1.0); + } + let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + check(&reopened, &name, component, origin, y, true); + run(&mut reopened, "component.activate", json!({"component":component})); + run(&mut reopened, "sheet.unfold", json!({"body":name})); + check(&reopened, &name, component, origin, y, true); +} + #[test] fn base_and_edge_flange_follow_the_k_factor() { let mut s = Session::default(); @@ -344,3 +701,405 @@ 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))); +} + +// A profile chosen by a point outside its inner loop must keep that inner loop through +// sheet construction, flattening and manufacturing export. +#[test] +fn inherited_base_profile_holes_survive_every_sheet_state() { + for flip in [false, true] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 20], "p1": [60, 40]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip})); + assert!((volume(&mut s) - 14000.0).abs() < 1e-6); + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"})); + run(&mut reopened, "sketch.finish", json!({})); + let pick = if flip { [10, -10] } else { [10, 10] }; + run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": pick}], "distance": 2.5})); + assert!((volume(&mut reopened) - 14000.0).abs() < 1e-5); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 14000.0).abs() < 1e-6); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - 14000.0).abs() < 1e-6); + } +} + +#[test] +fn edge_notch_uses_the_same_material_boundary_in_solid_flat_and_dxf() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Notch"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, 20], "p1": [10, 40]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Notch", "through_all": true, "operation": "cut", "direction": "symmetric"})); + assert!((volume(&mut s) - 14500.0).abs() < 1e-6); + let f = flat(&mut s); + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1)); + assert_eq!(f["outline"][0].as_array().map(Vec::len), Some(8)); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"})); + run(&mut reopened, "sketch.finish", json!({})); + run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "distance": 2.5})); + assert!((volume(&mut reopened) - 14500.0).abs() < 1e-5); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 14500.0).abs() < 1e-6); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - 14500.0).abs() < 1e-6); +} + +#[test] +fn inherited_round_hole_survives_flange_and_unfold_in_flipped_frames() { + for flip in [false, true] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); + run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip})); + run(&mut s, "sheet.flange", json!({"edges": [[50, 0, if flip { -2.5 } else { 2.5 }]], "height": 20})); + let folded = volume(&mut s); + assert!((folded - (18972.62196081845 - PI * 25.0 * 2.5)).abs() < 0.2, "{folded}"); + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1)); + let sh = s.model.state().sheets[0].clone(); + let regions = sh.flat_regions().unwrap(); + assert_eq!(regions[0].holes[0].segs.len(), 2); + assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); + let expected = regions.iter().map(solvecraft_geom::Region2::area).sum::() * 2.5; + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.1); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - folded).abs() < 0.1); + } +} + +#[test] +fn crossing_only_disconnecting_cut_preserves_all_material_states() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Split"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45, -5], "p1": [55, 65]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Split", "through_all": true, "operation": "cut", "direction": "symmetric"})); + assert!((volume(&mut s) - 13500.0).abs() < 1e-6); + assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "SplitBlank"})); + run(&mut reopened, "sketch.finish", json!({})); + run(&mut reopened, "solid.extrude", json!({"sketch": "SplitBlank", "distance": 2.5})); + assert!((volume(&mut reopened) - 13500.0).abs() < 1e-6); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 13500.0).abs() < 1e-6); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - 13500.0).abs() < 1e-6); +} + +#[test] +fn splitting_an_unfolded_flanged_sheet_preserves_both_bent_pieces() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sheet.flange", json!({"edges": [[50,0,2.5]], "height": 20})); + let folded = volume(&mut s); + run(&mut s, "sheet.unfold", json!({})); + let unfolded = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BlankSplit"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45,-100], "p1": [55,100]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "BlankSplit", "through_all": true, "operation": "cut", "direction": "symmetric"})); + assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1); + assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - folded * 0.9).abs() < 0.2); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1); +} + +#[test] +fn repeated_and_nested_sheet_cuts_are_redundant() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + for (i, radius) in [3.0, 5.0, 5.0].into_iter().enumerate() { + let name = format!("Hole{i}"); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": name})); + run(&mut s, "sketch.circle.center", json!({"center": [20, 30], "radius": radius})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"})); + } + assert!((volume(&mut s) - (6000.0 - PI * 25.0) * 2.5).abs() < 0.1); + assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1)); +} + +#[test] +fn overlapping_round_cuts_keep_exact_union_in_export_and_roundtrip() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + for (i, x) in [20.0, 22.0].into_iter().enumerate() { + let name = format!("Overlap{i}"); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": name})); + run(&mut s, "sketch.circle.center", json!({"center": [x, 30], "radius": 5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"})); + } + let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt(); + let expected = (6000.0 - (50.0 * PI - intersection)) * 2.5; + assert!((volume(&mut s) - expected).abs() < 0.1); + assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert!(dxf.contains("0\nARC\n")); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"})); + run(&mut reopened, "sketch.finish", json!({})); + run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": [10, 10]}], "distance": 2.5})); + assert!((volume(&mut reopened) - expected).abs() < 0.1); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.1); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.1); +} + +#[test] +fn cut_across_a_concave_notch_uses_crossings_not_just_contained_corners() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "U"})); + run(&mut s, "sketch.line", json!({"points": [[0,0],[100,0],[100,60],[70,60],[70,20],[30,20],[30,60],[0,60],[0,0]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "U"})); + assert!((volume(&mut s) - 11000.0).abs() < 1e-6); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "ArmNotches"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,30], "p1": [80,40]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "ArmNotches", "through_all": true, "operation": "cut", "direction": "symmetric"})); + assert!((volume(&mut s) - 10500.0).abs() < 1e-6); + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0)); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 10500.0).abs() < 1e-6); +} + +#[test] +fn tiny_cut_inside_exact_circle_survives_unfold_refold_and_dxf() { + for initially_flat in [false, true] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Disc"})); + run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 10})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Disc"})); + let circle = s.model.state().sheets[0].base.outer.clone(); + let displayed = circle.polyline(1e-3); + let at = (displayed[0] + displayed[1]).normalized().unwrap() * 9.9998; + assert!(!circle.contains(at)); + if initially_flat { + run(&mut s, "sheet.unfold", json!({})); + } + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TinyCut"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [at.x-0.000025,at.y-0.000025], "p1": [at.x+0.000025,at.y+0.000025]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "TinyCut", "through_all": true, "operation": "cut", "direction": "symmetric"})); + for stage in ["sheet.unfold", "sheet.refold"] { + let regions = s.model.state().sheets[0].flat_regions().unwrap(); + assert_eq!(regions[0].holes.len(), 1); + assert!((regions[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13); + assert_eq!(s.model.state().bodies.len(), 1); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 5); + if stage == "sheet.unfold" && initially_flat { + run(&mut s, "sheet.refold", json!({})); + } + run(&mut s, stage, json!({})); + } + assert_eq!(s.model.state().sheets[0].flat_regions().unwrap()[0].holes.len(), 1); + } +} + +#[test] +fn unrelated_box_cut_preserves_logical_ring_sheet_compound() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let sheet_name = s.model.state().sheets[0].body.clone(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"})); + for radius in [10, 5] { + run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": radius})); + } + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}), + ); + let material = s.model.state().sheets[0].flat_regions().unwrap(); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [200,0,0], "body_name": "SeparateBox"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BoxCut"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [205,5], "p1": [210,10]})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "BoxCut", "participants": ["SeparateBox"], "through_all": true, "operation": "cut", "direction": "symmetric"}), + ); + assert_eq!(s.model.state().bodies.len(), 2); + assert_eq!(s.model.state().sheets[0].flat_regions().unwrap(), material); + for stage in ["sheet.unfold", "sheet.refold"] { + run(&mut s, stage, json!({"body": sheet_name})); + assert_eq!(s.model.state().bodies.len(), 2); + let state = s.model.state(); + let body = &state.bodies.iter().find(|b| b.name == sheet_name).unwrap().body; + assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25))); + assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.25))); + } +} + +#[test] +fn specialized_sheet_cuts_respect_component_and_explicit_participants() { + for explicit in [false, true] { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let name = s.model.state().sheets[0].body.clone(); + run(&mut s, "component.create", json!({"name": "Other"})); + run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10, "body_name": "OtherBox"})); + if explicit { + run(&mut s, "component.activate", json!({"component": "root"})); + } + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,20], "p1": [30,30]})); + run(&mut s, "sketch.finish", json!({})); + let mut params = json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}); + if explicit { + params["participants"] = json!(["OtherBox"]); + } + run(&mut s, "solid.extrude", params); + let m = run(&mut s, "inspect.measure", json!({"bodies": [name]})); + assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 15000.0).abs() < 1e-6); + assert!(s.model.state().sheets[0].holes.is_empty()); + let m = run(&mut s, "inspect.measure", json!({"bodies": ["OtherBox"]})); + assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 59000.0).abs() < 1e-6); + } +} + +#[test] +fn ring_cut_preserves_retained_island_through_unfold_and_refold() { + fn material_probes(s: &Session) { + let body = &s.model.state().bodies[0].body; + assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25))); + assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.25))); + assert!(body.contains(solvecraft_geom::Vec3::new(65.0, 30.0, 1.25))); + } + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"})); + run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": 10})); + run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": 5})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}), + ); + let expected = (6000.0 - PI * 75.0) * 2.5; + assert!((volume(&mut s) - expected).abs() < 0.2); + material_probes(&s); + assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); + let regions = s.model.state().sheets[0].flat_regions().unwrap(); + assert!(regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(50.0, 30.0)))); + assert!(!regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(57.0, 30.0)))); + assert!(regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(65.0, 30.0)))); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "RingBlank"})); + run(&mut reopened, "sketch.finish", json!({})); + run(&mut reopened, "solid.extrude", json!({"sketch": "RingBlank", "profiles": [{"point": [10,10]}, {"point": [50,30]}], "distance": 2.5})); + assert!((volume(&mut reopened) - expected).abs() < 0.2); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.2); + material_probes(&s); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.2); + material_probes(&s); +} diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 3ba23f84..ffdfd478 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -9,6 +9,12 @@ use super::{CommandSpec, in_sketch}; use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3}; use crate::{EngineError, Result, Session}; +#[path = "sketch_dimensioned.rs"] +mod dimensioned; +#[path = "sketch_face.rs"] +pub(super) mod face; +use dimensioned::draw as draw_dimensioned; + /// The `connect` parameter every drawing command takes (see `connect_points`). macro_rules! connect_doc { () => { @@ -20,8 +26,8 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.create", "Create Sketch", create_sketch) .at("SOLID", "CREATE") .icon("sketch") - .params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"), - CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"), + .params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"), + CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"), CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(), CommandSpec::new("sketch.line", "Line", draw_line) .at("SKETCH", "CREATE") @@ -29,6 +35,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("L") .enabled(in_sketch) .params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())), + CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned) + .enabled(in_sketch) + .params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step", created_doc!())), CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point) .at("SKETCH", "CREATE") .icon("rect") @@ -216,10 +225,16 @@ pub(super) fn edit( let out = f(&mut sk, &mut doc)?; let (vals, _) = doc.param_values(); doc.apply_dimension_values(&vals, &mut sk)?; - let rep = solve(&mut sk); + sk.refresh_offsets()?; + let mut rep = solve(&mut sk); + if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() { + sk.refresh_offsets()?; + rep = solve(&mut sk); + } if strict && !rep.ok() { return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", ")))); } + sk.validate_periodic_splines()?; *doc.sketch_mut(id)? = sk; *s.doc_mut() = doc; Ok((out, json!({"solved": rep.ok(), "dof": rep.dof}))) @@ -376,35 +391,56 @@ pub(super) fn result(sk_out: (Vec, Vec), info: Value) -> Value { // --------------------------------------------------------------------------------------------- // Sketch lifecycle +pub(crate) fn author_path_for_rebind(s: &Session, feature: &solvecraft_doc::Feature, p: &Value, cmd: &str) -> Result> { + let mut saved = None; + if let FeatureKind::Sketch { plane, .. } = &feature.kind { + let mut plane = plane; + loop { + match plane { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base, + PlaneRef::Face { anchor: Some(anchor), .. } => { + saved = Some(anchor.author_path.clone()); + break; + } + _ => break, + } + } + } + let path = if let Some(value) = p.get("author_path") { + value + .as_array() + .filter(|path| path.len() <= 64) + .ok_or_else(|| bad(cmd, "author_path must contain at most64 ids"))? + .iter() + .map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "invalid author_path id"))) + .collect::>>()? + } else if let Some(path) = saved { + path + } else if feature.component == s.active_component && s.active_occurrence_path.is_some() { + s.active_occurrence_path.clone().unwrap_or_default() + } else { + let placements = super::component::properties::placements(&s.placed_document(), cmd)?; + let paths: Vec<_> = placements.into_iter().filter(|(component, _, _)| *component == feature.component).map(|(_, path, _)| path).collect(); + if paths.len() != 1 { + return Err(bad(cmd, "sketch author placement is ambiguous; provide exact author_path or activate the intended occurrence")); + } + paths.into_iter().next().ok_or_else(|| bad(cmd, "sketch author placement is missing"))? + }; + if feature.component != 0 && path.is_empty() { + return Err(bad(cmd, "a component sketch needs an explicit complete author_path")); + } + solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), feature.component, &path)?; + Ok(path) +} + pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { let base = match p.get("plane") { None => PlaneRef::Origin { name: "XY".into() }, // An origin plane, or a construction plane by name or id. Some(v @ (Value::String(_) | Value::Number(_))) => super::features::plane_param(s, Some(v), cmd)?, Some(v @ Value::Object(o)) => { - if let Some(fp) = o.get("face").and_then(vec3) { - // A planar face of a body at this point, as the active component sees the model - // (another component's face where it is placed). - let model = s.model.state(); - let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component); - let mut found = None; - let mut face_name = None; - for b in &st.bodies { - let tol = (b.body.size() * 1e-3).max(1e-3); - for f in b.body.faces(tol).unwrap_or_default() { - if let Some(n) = f.plane_normal - && (fp - f.centroid).dot(n).abs() < tol * 10.0 - && b.body.tessellate(tol).map(|m| face_contains(&m, f.index, fp, tol * 10.0)).unwrap_or(false) - { - // Sketch origin: the model origin projected onto the face plane. - let d = (f.centroid + n * (fp - f.centroid).dot(n)).dot(n); - found = Plane::from_normal(n * d, n); - face_name = solvecraft_doc::naming::face_names(b).get(f.index).cloned(); - } - } - } - let pl = found.ok_or_else(|| bad(cmd, "no planar face at that point"))?; - PlaneRef::Face { plane: pl, at: fp, name: face_name } + if o.contains_key("face") { + face::capture(s, v, cmd)? } else { let origin = v.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); let x = v.get("x_dir").and_then(vec3).ok_or_else(|| bad(cmd, "plane needs x_dir"))?; @@ -421,7 +457,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { } /// Does triangle set of face `fi` contain point `p` (within `tol` of the face plane)? -fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool { +pub(super) fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool { m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == fi).any(|(t, _)| { let Some([a, b, c]) = m.tri(t) else { return false }; let n = (b - a).cross(c - a); @@ -441,13 +477,28 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo fn create_sketch(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.create"; - let plane = plane_ref(s, p, cmd)?; + create_sketch_with_plane(s, p, plane_ref(s, p, cmd)?, None) +} + +/// Used only by face Extrude/Revolve/PressPull, preserving pre-certificate linked profiles. +pub(super) fn create_face_profile(s: &mut Session, p: &Value, body: &str, index: usize) -> Result { + let value = p.get("plane").ok_or_else(|| bad("face profile", "missing picked face plane"))?; + let plane = face::capture_internal(s, value, "face profile")?; + create_sketch_with_plane(s, p, plane, Some((body, index))) +} + +fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result { + if face::is_face(p) { + face::capture_positions(s, &plane)?; + } // Validate the plane resolves. let (vals, _) = s.doc.param_values(); s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?; let name = str_(p, "name"); let mut kind = FeatureKind::Sketch { plane, sketch: Sketch::new() }; - super::component::to_active_frame(s, &mut kind); + if !face::is_face(p) { + super::component::to_active_frame(s, &mut kind); + } let id = s.doc_mut().add_feature(kind, name)?; let comp = s.active_component; if let Some(f) = s.doc_mut().feature_mut(id) { @@ -462,8 +513,23 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result { && bool_(p, "project_edges").unwrap_or(s.auto_project) { link = super::sketch_project::auto_project_face(s, id, at)?; + if link.is_none() + && let Some((body, index)) = internal_body + { + link = Some(super::sketch_project::project_internal_face(s, id, body, index)?); + } } - Ok(json!({"sketch": id, "name": name, "projected": link})) + let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind { + FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() { + "exact_face" + } else if internal_body.is_some() { + "internal_legacy_face" + } else { + "fixed" + }), + _ => None, + }); + Ok(json!({"sketch": id, "name": name, "projected": link,"attachment":attachment})) } fn edit_sketch(s: &mut Session, p: &Value) -> Result { @@ -476,7 +542,17 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result { if !matches!(f.kind, FeatureKind::Sketch { .. }) { return Err(bad("sketch.edit", format!("`{key}` is not a sketch"))); } - let (id, name) = (f.id, f.name.clone()); + let (id, name, component) = (f.id, f.name.clone(), f.component); + let authored_path = match &f.kind { + FeatureKind::Sketch { plane, .. } => face::anchor(plane).map(|anchor| anchor.author_path.clone()), + _ => None, + }; + if let Some(path) = authored_path { + solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), component, &path) + .map_err(|error| bad("sketch.edit", format!("{error}; explicitly rebind the author placement before editing")))?; + s.active_component = component; + s.active_occurrence_path = (!path.is_empty()).then_some(path); + } s.active_sketch = Some(id); s.revision += 1; Ok(json!({"sketch": id, "name": name})) @@ -823,6 +899,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result { } fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result { + let mut support_circle = String::new(); let (out, info) = draw(s, p, cmd, |sk, _| { let corners: Vec = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect(); let (l, pts) = polygon_lines(sk, &corners)?; @@ -835,6 +912,7 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s // Construction circle tying the polygon to its centre. let rc = if inscribed { r_vertex } else { r_vertex * (std::f64::consts::PI / n as f64).cos() }; let circ = sk.add_circle(center, rc, None, None)?; + support_circle = sk.curves.get(circ).map(|c| c.id.clone()).unwrap_or_default(); if let Some(cu) = sk.curves.get_mut(circ) { cu.construction = true; } @@ -849,13 +927,15 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s } Ok((ids_of(sk, &l), cons)) })?; - Ok(result(out, info)) + let mut value = result(out, info); + value["support_circle"] = json!(support_circle); + Ok(value) } fn sides(p: &Value, cmd: &str) -> Result { let n = num(p, "sides").unwrap_or(6.0); - if !(3.0..=200.0).contains(&n) { - return Err(bad(cmd, "sides must be 3…200")); + if !(3.0..=200.0).contains(&n) || n.fract() != 0.0 { + return Err(bad(cmd, "sides must be an integer from 3 to 200")); } Ok(n as usize) } @@ -1364,9 +1444,18 @@ fn dimension(s: &mut Session, p: &Value) -> Result { // The other reading of an angle between two lines (see below). let mut alt: Option<(ConstraintKind, f64)> = None; let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) { + ((None, Some(c)), None) if ty == "sweep" => { + let angle = arc_len(sk, c).zip(sk.radius(c)).map(|(len, r)| len / r).ok_or_else(|| bad(cmd, "sweep needs an arc"))?; + let angle = if sk.curves.get(c).is_some_and(|c| c.reversed) { -angle } else { angle }; + (ConstraintKind::ArcSweep { c, value: angle }, angle) + } ((None, Some(c)), None) if is_line(c) => { let (a, b) = line_pts(sk, c).unwrap_or_default(); match ty.as_str() { + "angle" => { + let angle = (b - a).angle(); + (ConstraintKind::LineAngle { l: c, value: angle }, angle) + } "horizontal" => { (ConstraintKind::DistanceX { p: line_ends(sk, c).0, q: line_ends(sk, c).1, value: (b.x - a.x).abs() }, (b.x - a.x).abs()) } @@ -1415,6 +1504,15 @@ fn dimension(s: &mut Session, p: &Value) -> Result { (ConstraintKind::PointLineDistance { p: q, l, value: d }, d) } } + ((None, Some(a)), Some((None, Some(b)))) if ty == "overall_length" => { + let current = sk + .center(a) + .zip(sk.center(b)) + .zip(sk.radius(a).zip(sk.radius(b))) + .map(|((ca, cb), (ra, rb))| ca.dist(cb) + ra + rb) + .ok_or_else(|| bad(cmd, "overall slot length needs two arc caps"))?; + (ConstraintKind::SlotOverallLength { a, b, value: current }, current) + } ((None, Some(a)), Some((None, Some(b)))) if is_line(a) && is_line(b) => { let (a0, a1) = line_pts(sk, a).unwrap_or_default(); let (b0, b1) = line_pts(sk, b).unwrap_or_default(); @@ -1755,7 +1853,7 @@ fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) { } k => { // Lines, splines and conics slide with the cursor. - for q in k.point_ids() { + for q in k.point_ids().into_iter().collect::>() { if let Some(at) = sk.point(q) { set(sk, q, at + d); } diff --git a/crates/engine/src/cmd/sketch_3d.rs b/crates/engine/src/cmd/sketch_3d.rs index e6e27ebe..bdd65665 100644 --- a/crates/engine/src/cmd/sketch_3d.rs +++ b/crates/engine/src/cmd/sketch_3d.rs @@ -4,8 +4,9 @@ use serde_json::{Value, json}; use solvecraft_geom::Vec3; +use solvecraft_sketch::WireSpace; -use super::sketch::edit; +use super::sketch::{edit, target_sketch}; use super::{CommandSpec, in_sketch}; use crate::params::{bad, req_vec3, vec3}; use crate::{Result, Session}; @@ -38,32 +39,71 @@ fn needs_3d(sk: &solvecraft_sketch::Sketch, cmd: &str) -> Result<()> { if sk.view.three_d { Ok(()) } else { Err(bad(cmd, "turn on 3D Sketch (sketch.options sketch_3d: true) to draw off the sketch plane")) } } +/// World command inputs belong to the target sketch's owner, which may differ from the +/// active component when a script supplies an explicit sketch. +fn inverse_owner_frame(s: &Session, p: &Value, cmd: &str) -> Result { + let id = target_sketch(s, p, cmd)?; + let owner = s.doc.feature(id).map(|f| f.component).ok_or_else(|| bad(cmd, "target sketch is missing"))?; + if owner != 0 && owner != s.active_component { + if s.doc.occurrences.len() > 100_000 { + return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds")); + } + let mut children: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for occurrence in &s.doc.occurrences { + children.entry(occurrence.parent).or_default().push(occurrence); + } + let mut stack = vec![(0, 0usize)]; + let mut count = 0; + let mut visited = 0; + while let Some((component, depth)) = stack.pop() { + for o in children.get(&component).into_iter().flatten() { + visited += 1; + if depth >= 64 || visited > 100_000 { + return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds")); + } + if o.component == owner { + count += 1; + if count > 1 { + return Err(bad(cmd, "activate the desired sketch owner occurrence before editing world 3D points")); + } + } + stack.push((o.component, depth + 1)); + } + } + } + let frame = if owner == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(owner) }; + solvecraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the sketch owner placement is not invertible")) +} + fn line3d(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.line3d"; - let pts = points(cmd, p, 2)?; + let inverse = inverse_owner_frame(s, p, cmd)?; + let pts: Vec = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect(); let (ids, info) = edit(s, p, cmd, false, |sk, _| { needs_3d(sk, cmd)?; - pts.windows(2).map(|w| sk.add_drawn_wire(w).map_err(Into::into)).collect::>>() + pts.windows(2).map(|w| sk.add_drawn_wire_in(w, WireSpace::Component).map_err(Into::into)).collect::>>() })?; Ok(json!({"curves": ids, "sketch": info})) } fn spline3d(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.spline3d"; - let pts = points(cmd, p, 2)?; + let inverse = inverse_owner_frame(s, p, cmd)?; + let pts: Vec = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect(); let (id, info) = edit(s, p, cmd, false, |sk, _| { needs_3d(sk, cmd)?; - Ok(sk.add_drawn_wire(&pts)?) + Ok(sk.add_drawn_wire_in(&pts, WireSpace::Component)?) })?; Ok(json!({"curves": [id], "sketch": info})) } fn point3d(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.point3d"; - let at = req_vec3(cmd, p, "point")?; + let inverse = inverse_owner_frame(s, p, cmd)?; + let at = solvecraft_doc::apply_point(&inverse, req_vec3(cmd, p, "point")?); let (id, info) = edit(s, p, cmd, false, |sk, _| { needs_3d(sk, cmd)?; - Ok(sk.add_drawn_wire(&[at])?) + Ok(sk.add_drawn_wire_in(&[at], WireSpace::Component)?) })?; Ok(json!({"point": id, "sketch": info})) } @@ -71,23 +111,41 @@ fn point3d(s: &mut Session, p: &Value) -> Result { fn move3d(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.move3d"; let wire = p.get("wire").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`wire` must be a 3D curve or point id"))?.to_string(); - let index = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; - let to = p.get("to").and_then(vec3); - let by = p.get("by").and_then(vec3); + let index = match p.get("index") { + None => 0, + Some(v) => v + .as_u64() + .and_then(|v| usize::try_from(v).ok()) + .filter(|v| *v < solvecraft_sketch::MAX_FIT_POINTS) + .ok_or_else(|| bad(cmd, "`index` must be a fit point index below 500"))?, + }; + let parse = |name| p.get(name).map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{name}` must be [x,y,z]")))).transpose(); + let to = parse("to")?; + let by = parse("by")?; + let inverse = inverse_owner_frame(s, p, cmd)?; let (n, info) = edit(s, p, cmd, false, |sk, _| { - let from = sk + let drawn = sk .wire_index(&wire) .and_then(|w| sk.wires.get(w)) - .filter(|w| !w.fit.is_empty()) - .and_then(|w| w.fit.get(index)) - .copied() - .ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?; + .filter(|w| !w.fit.is_empty() && w.link.is_none()) + .ok_or_else(|| bad(cmd, format!("`{wire}` is not an editable drawn 3D curve")))?; + let from = drawn.fit.get(index).copied().ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?; let to = match (to, by) { - (Some(t), _) => t, - (None, Some(d)) => from + d, + (Some(t), _) => { + if drawn.space == WireSpace::Component { + solvecraft_doc::apply_point(&inverse, t) + } else { + t + } + } + (None, Some(d)) => from + if drawn.space == WireSpace::Component { solvecraft_doc::apply_vector(&inverse, d) } else { d }, _ => return Err(bad(cmd, "give `to` or `by`")), }; Ok(sk.move_drawn_point(&wire, index, to)?) })?; Ok(json!({"moved": n, "sketch": info})) } + +#[cfg(test)] +#[path = "sketch_3d_frame_tests.rs"] +mod frame_tests; diff --git a/crates/engine/src/cmd/sketch_3d_frame_tests.rs b/crates/engine/src/cmd/sketch_3d_frame_tests.rs new file mode 100644 index 00000000..12f2a0dd --- /dev/null +++ b/crates/engine/src/cmd/sketch_3d_frame_tests.rs @@ -0,0 +1,312 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; +use solvecraft_sketch::WireSpace; + +fn run(s: &mut Session, cmd: &str, params: Value) -> Value { + s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn near(actual: Vec3, expected: Vec3) { + assert!(actual.dist(expected) < 1e-7, "{actual:?} != {expected:?}"); +} + +fn second_frame() -> (Session, u64, u64, u64) { + let mut s = Session::default(); + let c = run(&mut s, "component.create", json!({"name":"Wire owner"})); + let component = c["component"].as_u64().unwrap(); + let first = c["occurrence"].as_u64().unwrap(); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap(); + // One rigid transform: (x,y,z) -> (20-y,30+x,40+z), independent of implementation math. + run(&mut s, "occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]})); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"Space path"})); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + (s, component, first, second) +} + +#[test] +fn wire_frame_second_occurrence_world_inputs_move_and_reopen() { + let (mut s, component, first, second) = second_frame(); + let sketch = s.active_sketch.unwrap(); + let r = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55],[14,32,55]]})); + let a = r["curves"][0].as_str().unwrap().to_string(); + let b = r["curves"][1].as_str().unwrap().to_string(); + let spline = run(&mut s, "sketch.spline3d", json!({"points":[[14,32,55],[12,34,56],[10,35,58]]}))["curves"][0].as_str().unwrap().to_string(); + let point = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string(); + let sk = s.doc.sketch(sketch).unwrap(); + for w in &sk.wires { + assert_eq!(w.space, WireSpace::Component); + } + near(sk.wires[sk.wire_index(&a).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0)); + near(sk.wires[sk.wire_index(&point).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0)); + let world = s.world_state(); + let ws = &world.sketch(sketch).unwrap().sketch; + let sw = &ws.wires[ws.wire_index(&spline).unwrap()]; + near(sw.fit[0], Vec3::new(14.0, 32.0, 55.0)); + near(sw.pts[0], sw.fit[0]); + near(*sw.pts.last().unwrap(), Vec3::new(10.0, 35.0, 58.0)); + let before = s.doc.to_json(); + let moved = run(&mut s, "sketch.move3d", json!({"wire":a,"index":1,"by":[1,0,0]})); + assert_eq!(moved["moved"], 2); + let sk = s.doc.sketch(sketch).unwrap(); + near(sk.wires[sk.wire_index(&a).unwrap()].fit[1], Vec3::new(2.0, 2.0, 15.0)); + near(sk.wires[sk.wire_index(&b).unwrap()].fit[0], Vec3::new(2.0, 2.0, 15.0)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + run(&mut s, "sketch.move3d", json!({"wire":point,"to":[18,33,46]})); + near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0)); + let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(restored.sketch(sketch).unwrap().wires, s.doc.sketch(sketch).unwrap().wires); + // Same definition at the first placement has the local geometry; selected second is exact. + run(&mut s, "component.activate", json!({"component":component,"occurrence":first,"path":[first]})); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0)); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(18.0, 33.0, 46.0)); +} + +#[test] +fn wire_frame_pending_moves_other_owner_and_legacy_world_do_not_join() { + let (mut s, _, _, second) = second_frame(); + let sketch = s.active_sketch.unwrap(); + let legacy = s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(2.0, 3.0, 5.0)]).unwrap(); + let local = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string(); + assert_eq!(run(&mut s, "sketch.move3d", json!({"wire":local,"by":[1,0,0]}))["moved"], 1); + near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0)); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); + let point = run(&mut s, "sketch.point3d", json!({"point":[22,32,45]}))["point"].as_str().unwrap().to_string(); + near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0)); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0)); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0)); + run(&mut s, "component.capture_position", json!({})); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0)); + // Explicit target in root must not inherit the unrelated active component frame. + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "component.activate", json!({"component":0})); + let root = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let unrelated = run(&mut s, "component.create", json!({"name":"Unrelated"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":unrelated,"translate":[100,200,300]})); + run(&mut s, "sketch.point3d", json!({"sketch":root,"point":[9,8,7]})); + near(s.doc.sketch(root).unwrap().wires[0].fit[0], Vec3::new(9.0, 8.0, 7.0)); +} + +#[test] +fn wire_frame_hostile_inputs_rollback_full_session() { + let (mut s, _, _, second) = second_frame(); + let id = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string(); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[1,0,0],"capture":false})); + run(&mut s, "select.set", json!({"items":[{"type":"occurrence","path":[second]}]})); + let doc = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let selection = s.selection.clone(); + let history = (s.undo.len(), s.redo.len()); + for (cmd, p) in [ + ("sketch.move3d", json!({"wire":id,"index":u64::MAX,"to":[0,0,0]})), + ("sketch.move3d", json!({"wire":id,"index":-1,"to":[0,0,0]})), + ("sketch.move3d", json!({"wire":id,"index":"0","to":[0,0,0]})), + ("sketch.move3d", json!({"wire":id,"to":[null,0,0],"by":[1,0,0]})), + ("sketch.point3d", json!({"point":[1e100,0,0]})), + ("sketch.line3d", json!({"points":[[17,32,45],[17,32,45]]})), + ("sketch.spline3d", json!({"points":vec![[0,0,0];501]})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd}: {p}"); + assert_eq!(s.doc.to_json(), doc); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.selection, selection); + assert_eq!((s.undo.len(), s.redo.len()), history); + } +} + +#[test] +fn wire_frame_include_other_component_refresh_break_lost_and_reopen() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); + let component = run(&mut s, "component.create", json!({"name":"Receiver"})); + let occurrence = component["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[20,0,0]})); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"Include target"})); + let sketch = s.active_sketch.unwrap(); + run(&mut s, "sketch.include_3d", json!({"refs":[{"edge":[10,10,5],"body":"Body1"}]})); + let initial = s.doc.sketch(sketch).unwrap().wires[0].clone(); + assert_eq!(initial.space, WireSpace::Component); + assert!(initial.pts.iter().all(|p| (p.x + 10.0).abs() < 1e-7 && (p.y - 10.0).abs() < 1e-7)); + let link = initial.link.clone().unwrap(); + // A real sketch edit exercises engine refresh, not only document evaluation. + run(&mut s, "sketch.line", json!({"points":[[0,0],[1,0]]})); + let sk = s.doc.sketch(sketch).unwrap(); + assert_eq!(sk.wires[0], initial); + assert!(!sk.link(&link).unwrap().lost); + let world = s.world_state(); + assert!(world.sketch(sketch).unwrap().sketch.wires[0].pts.iter().all(|p| (p.x - 10.0).abs() < 1e-7)); + let before = s.doc.to_json(); + run(&mut s, "sketch.break_link", json!({"link":link})); + assert_eq!(s.doc.sketch(sketch).unwrap().wires[0].space, WireSpace::Component); + assert!(s.doc.sketch(sketch).unwrap().wires[0].link.is_none()); + let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(restored.sketch(sketch).unwrap().wires[0], s.doc.sketch(sketch).unwrap().wires[0]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + // Lost references retain the last coordinates and explicit marker, with no guessing. + let sk = s.doc_mut().sketch_mut(sketch).unwrap(); + sk.links.iter_mut().find(|l| l.id == link).unwrap().source = solvecraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO }; + run(&mut s, "sketch.line", json!({"points":[[2,0],[3,0]]})); + let sk = s.doc.sketch(sketch).unwrap(); + assert!(sk.link(&link).unwrap().lost); + assert_eq!(sk.wires[0], initial); + let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(restored.sketch(sketch).unwrap().wires[0], initial); +} + +#[test] +fn wire_frame_pipe_component_and_world_paths_have_analytic_volume_and_position() { + for space in [WireSpace::Component, WireSpace::World] { + let (mut s, _, first, second) = second_frame(); + if space == WireSpace::World { + // A single placed owner lets an immutable world path stay physically fixed; + // replicated solids still follow their occurrence placements by design. + run(&mut s, "selection.delete", json!({"items":[{"type":"occurrence","path":[first]}]})); + } + let sketch = s.active_sketch.unwrap(); + let wire = if space == WireSpace::Component { + run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string() + } else { + s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)]).unwrap() + }; + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.pipe", json!({"path_sketch":sketch,"path":[wire],"diameter":4})); + let world = s.world_state(); + let body = world.bodies.last().unwrap(); + // Property measurement uses fine adaptive tessellation; the viewport mesh alone + // undershoots a small circular profile by about 0.64% at its display chord tolerance. + let measure = solvecraft_kernel::measure(&body.body).unwrap(); + assert!((measure.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", measure.volume); + let bounds = body.mesh().bounds(); + near((bounds.min + bounds.max) / 2.0, Vec3::new(17.0, 32.0, 50.0)); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0]})); + let world = s.world_state(); + let body = world.bodies.last().unwrap(); + let bounds = body.mesh().bounds(); + near((bounds.min + bounds.max) / 2.0, Vec3::new(if space == WireSpace::Component { 22.0 } else { 17.0 }, 32.0, 50.0)); + } +} + +#[test] +fn wire_frame_nonactive_repeated_owner_refuses_ambiguous_world_input() { + let (mut s, _, _, _) = second_frame(); + let target = s.active_sketch.unwrap(); + run(&mut s, "component.activate", json!({"component":0})); + let before = s.doc.to_json(); + let error = s.execute("sketch.point3d", &json!({"sketch":target,"point":[0,0,5]})).unwrap_err(); + assert!(error.to_string().contains("activate the desired sketch owner occurrence")); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn wire_frame_wide_owner_graph_has_bounded_indexed_traversal() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name":"Target"})); + let target = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":0})); + // A legal wide assembly used to force one whole occurrence scan for each leaf. + let doc = s.doc_mut(); + for i in 0..9998u64 { + doc.components.push(solvecraft_doc::Component { id: 10_000 + i, name: format!("Sibling{i}"), parent: 0 }); + doc.occurrences.push(solvecraft_doc::Occurrence { + id: 100_000 + i, + component: 10_000 + i, + parent: 0, + name: format!("Sibling{i}"), + transform: solvecraft_doc::IDENTITY, + grounded: false, + }); + } + assert_eq!(super::inverse_owner_frame(&s, &json!({"sketch":target}), "sketch.point3d").unwrap(), solvecraft_doc::IDENTITY); +} + +#[test] +fn wire_frame_cross_owner_sweep_follows_world_path_edit_undo_and_reopen() { + let mut s = Session::default(); + let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let wire = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + let owner = run(&mut s, "component.create", json!({"name":"Sweep owner"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":owner,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]})); + let profile = run(&mut s, "sketch.create", json!({"plane":"XY","offset":5,"name":"Profile"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.circle.center", json!({"center":[2,3],"radius":2})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"})); + let check = |s: &Session, length: f64| { + let world = s.world_state(); + let body = world.bodies.last().unwrap(); + let measure = solvecraft_kernel::measure(&body.body).unwrap(); + assert!((measure.volume - 4.0 * std::f64::consts::PI * length).abs() < 0.05, "{}", measure.volume); + near(measure.centroid, Vec3::new(17.0, 32.0, 45.0 + length / 2.0)); + }; + check(&s, 10.0); + let saved = s.doc.to_json(); + run(&mut s, "sketch.edit", json!({"sketch":path_sketch})); + run(&mut s, "sketch.move3d", json!({"sketch":path_sketch,"wire":wire,"index":1,"by":[0,0,10]})); + check(&s, 20.0); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), saved); + check(&s, 10.0); + let mut reopened = Session { doc: solvecraft_doc::format::read(&saved).unwrap().into(), ..Default::default() }; + reopened.refresh(); + check(&reopened, 10.0); +} + +#[test] +fn wire_frame_three_owner_sweep_profile_move_invalidates_warm_cache() { + let mut s = Session::default(); + let profile_owner = run(&mut s, "component.create", json!({"name":"Profile owner","parent":0})); + let profile_occurrence = profile_owner["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[4,7,5]})); + let profile = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign profile"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":2})); + run(&mut s, "sketch.finish", json!({})); + + let path_owner = run(&mut s, "component.create", json!({"name":"Path owner","parent":0})); + let path_occurrence = path_owner["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":path_occurrence,"translate":[-12,9,-3]})); + let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign path"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let wire = run(&mut s, "sketch.line3d", json!({"points":[[4,7,5],[4,7,15]]}))["curves"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + + let consumer = run(&mut s, "component.create", json!({"name":"Sweep consumer","parent":0})); + let consumer_occurrence = consumer["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":consumer_occurrence,"axis":[0,0,1],"angle":"90 deg","translate":[100,-30,20]})); + run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"})); + let measure = |session: &Session, x: f64| { + let state = session.world_state(); + let body = state.bodies.last().unwrap(); + assert_eq!(body.component, consumer["component"].as_u64().unwrap()); + let result = solvecraft_kernel::measure(&body.body).unwrap(); + assert!((result.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", result.volume); + near(result.centroid, Vec3::new(x, 7.0, 10.0)); + result + }; + measure(&s, 4.0); + // A repeated refresh warms the exact same evaluator cache before changing only + // the foreign profile placement, whose frame must be part of Sweep's digest. + s.refresh(); + measure(&s, 4.0); + let saved = s.doc.to_json(); + let fixed_path = s.doc.component_transform(path_owner["component"].as_u64().unwrap()); + let fixed_consumer = s.doc.component_transform(consumer["component"].as_u64().unwrap()); + run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[3,0,0]})); + assert_eq!(s.doc.component_transform(path_owner["component"].as_u64().unwrap()), fixed_path); + assert_eq!(s.doc.component_transform(consumer["component"].as_u64().unwrap()), fixed_consumer); + let warm = measure(&s, 7.0); + let mut cold = Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), ..Default::default() }; + cold.refresh(); + let reopened = measure(&cold, 7.0); + near(warm.centroid, reopened.centroid); + assert!((warm.volume - reopened.volume).abs() < 1e-8); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), saved); + measure(&s, 4.0); +} diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs index 16d6ea57..d95ec5a0 100644 --- a/crates/engine/src/cmd/sketch_constraints.rs +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -434,8 +434,9 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option { } Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5), PointLineDistance { p, .. } | LinearDiameter { p, .. } => pt(p), - Length { l, .. } => mid(l), - Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } => mid(c), + Length { l, .. } | LineAngle { l, .. } => mid(l), + Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } | ArcSweep { c, .. } => mid(c), + SlotOverallLength { a, b, .. } => Some((sk.center(a)? + sk.center(b)?) * 0.5), } } diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index a8cd4272..92d61a99 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -53,7 +53,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CREATE") .icon("spline") .enabled(in_sketch) - .params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them", created_doc!())), + .params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())), CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3) .at("SKETCH", "CREATE") .icon("spline_cv") @@ -165,7 +165,7 @@ fn blend(s: &mut Session, p: &Value) -> Result { let first_new = sk.points.len(); let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; - let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3 }, None)?; + let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; solve_new_only(sk, first_new); Ok((ids_of(sk, &[sp]), cons)) @@ -250,10 +250,24 @@ fn point_list(sk: &mut Sketch, p: &Value, cmd: &str) -> Result> { } fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> Result { + let periodic = match p.get("periodic") { + None => false, + Some(Value::Bool(v)) => *v, + _ => return Err(bad(cmd, "periodic must be boolean")), + }; + if periodic && (control || degree != 3) { + return Err(bad(cmd, "periodic mode requires a cubic fit spline")); + } let p = &super::sketch_project::snap_points(s, p, cmd)?; let (out, info) = edit(s, p, cmd, false, |sk, _| { - let pts = point_list(sk, p, cmd)?; - let c = sk.add_curve(CurveKind::Spline { pts, control, degree }, None)?; + let mut pts = point_list(sk, p, cmd)?; + if periodic + && sk.point(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?) + != sk.point(*pts.last().ok_or_else(|| bad(cmd, "missing last point"))?) + { + pts.push(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?); + } + let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic }, None)?; mark_construction(sk, &[c], p); Ok((ids_of(sk, &[c]), Vec::new())) })?; @@ -342,12 +356,12 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option { None } } - CurveKind::Spline { ref pts, control, .. } => { + CurveKind::Spline { ref pts, control, periodic, .. } => { let q: Vec = pts.iter().filter_map(|i| sk.point(*i)).collect(); if pts.last() == Some(&pi) { - solvecraft_sketch::spline_end_tangent(&q, control, true) + solvecraft_sketch::spline_end_tangent_mode(&q, control, periodic, true) } else if pts.first() == Some(&pi) { - solvecraft_sketch::spline_end_tangent(&q, control, false).map(|t| -t) + solvecraft_sketch::spline_end_tangent_mode(&q, control, periodic, false).map(|t| -t) } else { None } diff --git a/crates/engine/src/cmd/sketch_dimensioned.rs b/crates/engine/src/cmd/sketch_dimensioned.rs new file mode 100644 index 00000000..f9ed36c9 --- /dev/null +++ b/crates/engine/src/cmd/sketch_dimensioned.rs @@ -0,0 +1,114 @@ +//! Shape creation and its typed driving dimensions form one command and one undo step. +use serde_json::{Value, json}; +use solvecraft_doc::expr::Kind; + +use crate::{ + Result, Session, + params::{bad, expr, str_}, +}; + +pub(super) fn draw(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.draw.dimensioned"; + let create = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name a supported sketch creator"))?; + let roles: &[&str] = match create { + "sketch.line" => &["length", "angle"], + "sketch.rectangle.two_point" | "sketch.rectangle.center" | "sketch.rectangle.three_point" => &["width", "height"], + "sketch.circle.center" | "sketch.circle.two_point" | "sketch.circle.three_point" => &["radius", "diameter"], + "sketch.arc.center_point" => &["radius", "sweep"], + "sketch.slot.center_to_center" | "sketch.slot.overall" => &["length", "width"], + "sketch.polygon.inscribed" | "sketch.polygon.circumscribed" | "sketch.polygon.edge" => &["radius"], + _ => return Err(bad(cmd, "unsupported sketch creator")), + }; + let mut params = p.get("params").filter(|v| v.is_object()).cloned().ok_or_else(|| bad(cmd, "`params` must be an object"))?; + let dimensions = p.get("dimensions").and_then(Value::as_object).ok_or_else(|| bad(cmd, "`dimensions` must be an object"))?; + if dimensions.len() > roles.len() || dimensions.keys().any(|key| !roles.contains(&key.as_str())) { + return Err(bad(cmd, "unsupported dimension role for this creator")); + } + if dimensions.contains_key("radius") && dimensions.contains_key("diameter") { + return Err(bad(cmd, "choose radius or diameter, not both")); + } + let mut overall_length = None; + let mut slot_width = None; + for (role, value) in dimensions { + let e = expr(&json!({"value": value}), "value") + .filter(|e| e.len() <= 4096) + .ok_or_else(|| bad(cmd, "dimension values must be bounded expressions or finite numbers"))?; + let kind = if role == "angle" || role == "sweep" { Kind::Angle } else { Kind::Length }; + let evaluated = s.doc.eval(&e, kind)?; + if role == "length" { + overall_length = Some(evaluated); + } + if role == "width" { + slot_width = Some(evaluated); + } + if !evaluated.is_finite() || evaluated.abs() > 1e9 || kind == Kind::Length && evaluated <= 1e-9 { + return Err(bad(cmd, format!("invalid {role} dimension"))); + } + if role == "sweep" && !(evaluated.abs() > 1e-9 && evaluated.abs() < std::f64::consts::TAU - 1e-9) { + return Err(bad(cmd, "sweep must be nonzero and less than a full turn")); + } + if role == "sweep" { + params["sweep"] = json!(evaluated.to_degrees()); + if let Some(o) = params.as_object_mut() { + o.remove("end"); + } + } + } + if create == "sketch.slot.overall" + && let Some(length) = overall_length + && let Some(width) = slot_width.or_else(|| params.get("width").and_then(Value::as_f64)) + && length <= width + 1e-9 + { + return Err(bad(cmd, "overall slot length must exceed its width")); + } + if create == "sketch.line" { + if params.get("closed").and_then(Value::as_bool).unwrap_or(false) + || params.get("points").and_then(Value::as_array).is_none_or(|points| points.len() != 2) + { + return Err(bad(cmd, "dimensioned line creation needs exactly two points")); + } + if dimensions.contains_key("angle") { + params["infer"] = json!(false); + } + } + let mut scratch = s.scratch(); + let mut out = scratch.execute(create, ¶ms)?; + let curves = out.get("curves").and_then(Value::as_array).cloned().unwrap_or_default(); + let curve = |index: usize| -> Result { + curves.get(index).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return the expected curve")) + }; + let mut added = serde_json::Map::new(); + for (role, value) in dimensions { + let (entities, ty) = match (create, role.as_str()) { + ("sketch.line", "length") => (vec![curve(0)?], "length"), + ("sketch.line", "angle") => (vec![curve(0)?], "angle"), + (c, "width" | "height") if c.starts_with("sketch.rectangle.") => (vec![curve(usize::from(role == "height"))?], "length"), + (c, "radius" | "diameter") if c.starts_with("sketch.circle.") => (vec![curve(0)?], role.as_str()), + ("sketch.arc.center_point", "radius" | "sweep") => (vec![curve(0)?], role.as_str()), + ("sketch.slot.center_to_center", "length") => (vec![curve(0)?], "length"), + ("sketch.slot.overall", "length") => (vec![curve(1)?, curve(3)?], "overall_length"), + ("sketch.slot.center_to_center" | "sketch.slot.overall", "width") => (vec![curve(1)?], "diameter"), + (c, "radius") if c.starts_with("sketch.polygon.") => { + let support = + out.get("support_circle").filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "polygon support circle missing"))?; + (vec![support], "radius") + } + _ => return Err(bad(cmd, "unsupported dimension role")), + }; + let mut dimension = json!({"entities": entities, "type": ty, "value": value}); + if let Some(sketch) = params.get("sketch") { + dimension["sketch"] = sketch.clone(); + } + let result = scratch.execute("sketch.dimension", &dimension)?; + added.insert(role.clone(), result); + } + // Session::execute records the wrapper's before snapshot. Nested history must not leak. + scratch.undo.clear(); + s.accept_computed(scratch); + out["dimensions"] = Value::Object(added); + Ok(out) +} + +#[cfg(test)] +#[path = "sketch_dimensioned_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/sketch_dimensioned_tests.rs b/crates/engine/src/cmd/sketch_dimensioned_tests.rs new file mode 100644 index 00000000..7161c63f --- /dev/null +++ b/crates/engine/src/cmd/sketch_dimensioned_tests.rs @@ -0,0 +1,167 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_sketch::ConstraintKind; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}")) +} +fn session() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + s +} +fn sketch(s: &Session) -> solvecraft_sketch::Sketch { + s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone() +} +fn draw(s: &mut Session, command: &str, params: Value, dimensions: Value) -> Value { + run(s, "sketch.draw.dimensioned", json!({"command":command,"params":params,"dimensions":dimensions})) +} +fn close(a: f64, b: f64) { + assert!((a - b).abs() < 1e-6, "{a} != {b}"); +} +fn measure(s: &Session, kind: fn(&ConstraintKind) -> bool) -> f64 { + let sk = sketch(s); + let c = sk.constraints.iter().find(|c| kind(&c.kind)).unwrap(); + solvecraft_sketch::measure_dimension(&sk, &c.kind).unwrap() +} + +#[test] +fn typed_line_angle_survives_quadrants_move_reopen_and_one_undo() { + let mut s = session(); + run(&mut s, "parameters.add", json!({"name":"heading","expression":"-45 deg","unit":"deg"})); + let undo = s.undo.len(); + let out = draw(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]],"infer":true}), json!({"length":"10 mm","angle":"heading"})); + assert_eq!(s.undo.len(), undo + 1); + assert!(!sketch(&s).constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Horizontal { .. } | ConstraintKind::Vertical { .. }))); + close(measure(&s, |k| matches!(k, ConstraintKind::LineAngle { .. })), -45f64.to_radians()); + for degrees in [135.0_f64, 270.0, -135.0, 0.0, 90.0] { + let result = run(&mut s, "parameters.change", json!({"name":"heading","expression":format!("{degrees} deg")})); + assert_eq!(result["errors"], json!([])); + close(measure(&s, |k| matches!(k, ConstraintKind::LineAngle { .. })), degrees.to_radians()); + close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. })), 10.0); + } + let encoded = serde_json::to_value(&*s.doc).unwrap(); + let doc: solvecraft_doc::Document = serde_json::from_value(encoded).unwrap(); + let mut reopened = Session { doc: std::sync::Arc::new(doc), active_sketch: s.active_sketch, ..Session::default() }; + reopened.refresh(); + close(measure(&reopened, |k| matches!(k, ConstraintKind::LineAngle { .. })), 90f64.to_radians()); + run(&mut reopened, "sketch.move", json!({"entities":out["curves"],"translate":[4,3],"angle":20})); + close(measure(&reopened, |k| matches!(k, ConstraintKind::LineAngle { .. })), 90f64.to_radians()); + let mut atomic = session(); + draw(&mut atomic, "sketch.line", json!({"points":[[0,0],[5,5]]}), json!({"length":10,"angle":45})); + run(&mut atomic, "edit.undo", json!({})); + assert!(sketch(&atomic).curves.is_empty()); + assert!(atomic.doc.params.iter().all(|p| !p.model)); +} + +#[test] +fn signed_arc_sweep_retains_radius_and_logical_endpoint_identity() { + let mut s = session(); + run(&mut s, "parameters.add", json!({"name":"turn","expression":"-90 deg","unit":"deg"})); + let out = draw(&mut s, "sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,-10]}), json!({"radius":10,"sweep":"turn"})); + let id = out["curves"][0].as_str().unwrap().to_string(); + let original = sketch(&s).resolve_point(&format!("{id}.start")).unwrap(); + for degrees in [-90.0_f64, -270.0, 90.0, 270.0, -45.0] { + let result = run(&mut s, "parameters.change", json!({"name":"turn","expression":format!("{degrees} deg")})); + assert_eq!(result["errors"], json!([])); + let sk = sketch(&s); + assert_eq!(sk.resolve_point(&format!("{id}.start")), Some(original)); + assert_eq!(sk.curves[sk.curve_index(&id).unwrap()].reversed, degrees < 0.0); + close(measure(&s, |k| matches!(k, ConstraintKind::ArcSweep { .. })), degrees.to_radians()); + close(measure(&s, |k| matches!(k, ConstraintKind::Radius { .. })), 10.0); + } +} + +#[test] +fn slots_and_polygon_keep_permanent_typed_dimensions() { + for command in ["sketch.slot.center_to_center", "sketch.slot.overall"] { + let mut s = session(); + let out = draw(&mut s, command, json!({"p0":[2,3],"p1":[22,3],"width":4,"connect":false}), json!({"length":20,"width":4})); + let length = out["dimensions"]["length"]["param"].as_str().unwrap(); + let result = run(&mut s, "parameters.change", json!({"name":length,"expression":30})); + assert_eq!(result["errors"], json!([])); + close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::SlotOverallLength { .. })), 30.0); + let width = out["dimensions"]["width"]["param"].as_str().unwrap(); + let result = run(&mut s, "parameters.change", json!({"name":width,"expression":6})); + assert_eq!(result["errors"], json!([])); + close(measure(&s, |k| matches!(k, ConstraintKind::Diameter { .. })), 6.0); + close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::SlotOverallLength { .. })), 30.0); + } + for command in ["sketch.polygon.inscribed", "sketch.polygon.circumscribed"] { + let mut s = session(); + let out = draw(&mut s, command, json!({"center":[4,5],"radius":10,"sides":6,"connect":false}), json!({"radius":10})); + let support = out["support_circle"].as_str().unwrap(); + assert!(sketch(&s).curves[sketch(&s).curve_index(support).unwrap()].construction); + let radius = out["dimensions"]["radius"]["param"].as_str().unwrap(); + let result = run(&mut s, "parameters.change", json!({"name":radius,"expression":15})); + assert_eq!(result["errors"], json!([])); + close(measure(&s, |k| matches!(k, ConstraintKind::Radius { .. })), 15.0); + } +} + +#[test] +fn invalid_dimensioned_shapes_are_atomic_including_late_constraint_failure() { + let mut s = session(); + let before = serde_json::to_value(&*s.doc).unwrap(); + let history = s.undo.len(); + for params in [ + json!({"command":"edit.undo","params":{},"dimensions":{}}), + json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":"missing+1"}}), + json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":-1}}), + json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":"1".repeat(4097)}}), + json!({"command":"sketch.arc.center_point","params":{"center":[0,0],"start":[10,0]},"dimensions":{"sweep":360}}), + json!({"command":"sketch.polygon.inscribed","params":{"center":[0,0],"radius":10,"sides":3.5},"dimensions":{"radius":10}}), + json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":20,"angle":0}}), + ] { + // The last case connects both endpoints to fixed existing points and fails only after creation. + if params["dimensions"]["length"] == 20 { + run(&mut s, "sketch.point", json!({"point":[10,0]})); + run(&mut s, "sketch.constraint.fix", json!({"entity":"p1","fixed":true})); + } + let snapshot = serde_json::to_value(&*s.doc).unwrap(); + let undo = s.undo.len(); + // Preserve a live redo branch, which scratch-only dimension failure must not consume. + run(&mut s, "sketch.point", json!({"point":[100,100]})); + run(&mut s, "edit.undo", json!({})); + s.selection = vec![crate::Sel::SketchPoint { id: "origin".into() }]; + let redo = s.redo.len(); + let selection = s.selection.clone(); + let pending = s.pending_moves.clone(); + let active = (s.active_sketch, s.active_component, s.active_occurrence_path.clone()); + assert!(s.execute("sketch.draw.dimensioned", ¶ms).is_err(), "{params}"); + assert_eq!(serde_json::to_value(&*s.doc).unwrap(), snapshot); + assert_eq!(s.undo.len(), undo); + assert_eq!(s.redo.len(), redo); + assert_eq!(s.selection, selection); + assert_eq!(s.pending_moves, pending); + assert_eq!((s.active_sketch, s.active_component, s.active_occurrence_path.clone()), active); + } + assert_eq!(before["features"].as_array().unwrap().len(), 1); + assert!(s.undo.len() >= history); +} + +#[test] +fn dimensioned_authoring_uses_selected_occurrence_and_keeps_local_geometry() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap(); + let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0],"operation":"new"})); + s.auto_project = false; + run(&mut s, "sketch.create", json!({"plane":{"face":[21,1,2]}})); + let out = draw(&mut s, "sketch.line", json!({"points":[[0,0],[5,5]]}), json!({"length":5,"angle":45})); + let model = s.model.state(); + let local = model.sketch(s.active_sketch.unwrap()).unwrap(); + close(local.plane.origin.x, 0.0); + close(local.plane.origin.z, 2.0); + let world = s.world_state(); + let displayed = world.sketch(s.active_sketch.unwrap()).unwrap(); + close(displayed.plane.origin.x, 20.0); + close(displayed.plane.origin.z, 2.0); + assert_eq!(s.active_occurrence_path, Some(vec![second])); + let sk = sketch(&s); + let line = sk.curve_index(out["curves"][0].as_str().unwrap()).unwrap(); + let solvecraft_sketch::CurveKind::Line { a, b } = sk.curves[line].kind else { panic!("line") }; + close(sk.points[a].pos.x, 0.0); + close(sk.points[b].pos.x, 5.0 / std::f64::consts::SQRT_2); +} diff --git a/crates/engine/src/cmd/sketch_face.rs b/crates/engine/src/cmd/sketch_face.rs new file mode 100644 index 00000000..b71e0283 --- /dev/null +++ b/crates/engine/src/cmd/sketch_face.rs @@ -0,0 +1,215 @@ +//! Exact planar face capture for sketch attachment. World labels are lookup keys only. +use crate::params::{bad, vec3}; +use crate::{Result, Session}; +use serde_json::Value; +use solvecraft_doc::{EmbossFace, PlaneRef, apply_point, apply_vector, mat_inverse, mat_mul}; +use solvecraft_geom::Plane; + +pub(in crate::cmd) fn is_face(p: &Value) -> bool { + p.get("plane").and_then(|plane| plane.get("face")).is_some() +} + +pub(in crate::cmd) fn capture(s: &Session, value: &Value, cmd: &str) -> Result { + capture_impl(s, value, cmd, false) +} + +/// Internal face-profile compatibility only; never exposed as a command parameter. +pub(in crate::cmd) fn capture_internal(s: &Session, value: &Value, cmd: &str) -> Result { + capture_impl(s, value, cmd, true) +} + +fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result { + let point = value + .get("face") + .and_then(vec3) + .filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) + .ok_or_else(|| bad(cmd, "face point must be three finite world coordinates"))?; + let fixed = match value.get("fixed") { + None => false, + Some(Value::Bool(fixed)) => *fixed, + _ => return Err(bad(cmd, "fixed must be a boolean")), + }; + let state = s.model.state(); + let doc = s.placed_document(); + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component)); + let author_frame = EmbossFace::component_frame(&doc, s.active_component, &author_path)?; + let author_inverse = mat_inverse(&author_frame).ok_or_else(|| bad(cmd, "invalid sketch author frame"))?; + let requested_body = match value.get("body") { + None => None, + Some(Value::String(body)) if !body.is_empty() => Some(body.as_str()), + _ => return Err(bad(cmd, "body must be the picked world body name")), + }; + let requested_index = match value.get("index") { + None => None, + Some(value) => { + Some(value.as_u64().and_then(|index| usize::try_from(index).ok()).ok_or_else(|| bad(cmd, "index must be a nonnegative face index"))?) + } + }; + if requested_body.is_some() != requested_index.is_some() { + return Err(bad(cmd, "an exact face pick requires both body and index")); + } + let requested_path = match value.get("path") { + None => None, + Some(value) => Some( + value + .as_array() + .filter(|path| path.len() <= 64) + .ok_or_else(|| bad(cmd, "path must contain at most64 occurrence ids"))? + .iter() + .map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "path ids must be nonzero integers"))) + .collect::>>()?, + ), + }; + let selected_path = requested_path + .clone() + .or_else(|| requested_body.and_then(|name| state.body(name)).map(|body| doc.first_path(doc.model_body_component(body)))); + let placements = if let Some(path) = selected_path { + let component = path + .last() + .and_then(|id| doc.occurrences.iter().find(|occurrence| occurrence.id == *id)) + .map(|occurrence| occurrence.component) + .unwrap_or(0); + let frame = EmbossFace::component_frame(&doc, component, &path)?; + vec![(component, path, frame)] + } else { + crate::cmd::component::properties::placements(&doc, cmd)? + }; + let mut by_component: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for body in &state.bodies { + by_component.entry(doc.model_body_component(body)).or_default().push(body); + } + let mut chosen = None; + let mut work = state.bodies.len().saturating_add(doc.occurrences.len()); + for (component, path, placement) in placements { + let Some(bodies) = by_component.get(&component) else { + continue; + }; + for body in bodies { + if let Some(source) = requested_body.and_then(|name| state.body(name)) + && source.name != body.name + { + continue; + } + work = work.saturating_add(1); + if let Some(requested) = requested_body { + work = work.saturating_add(doc.occurrences.len()).saturating_add(state.bodies.len()).saturating_add(path.len()); + if work > 2_000_000 { + return Err(bad(cmd, "face capture work limit exceeded; supply exact path")); + } + if requested != doc.world_body_name(&state, &body.name, component, &path) { + continue; + } + } + let frame = doc.model_body_offset(body).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement); + let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?; + let local_point = apply_point(&inverse, point); + let tolerance = (body.body.size() * 1e-4).max(1e-5); + let faces = body.body.faces(tolerance)?; + let mesh = body.body.tessellate(tolerance)?; + work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() })); + if work > 2_000_000 { + return Err(bad(cmd, "face capture work limit exceeded; use an exact body and face index")); + } + for face in faces { + if requested_index.is_some_and(|index| index != face.index) { + continue; + } + let Some(exact_plane) = (if fixed { + body.body.planar_face(face.index).or_else(|| body.body.face_plane(face.index)) + } else { + body.body.face_plane(face.index).or_else(|| body.body.planar_face(face.index)) + }) else { + continue; + }; + let normal = exact_plane.normal(); + if (local_point - face.centroid).dot(normal).abs() > tolerance * 10.0 + || !super::face_contains(&mesh, face.index, local_point, tolerance * 10.0) + { + continue; + } + if chosen.is_some() { + return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index")); + } + let relative = mat_mul(&author_inverse, &frame); + let normal = apply_vector(&relative, normal).normalized().ok_or_else(|| bad(cmd, "invalid planar face normal"))?; + let centroid = apply_point(&relative, face.centroid); + let picked = Plane::from_normal(normal * centroid.dot(normal), normal).ok_or_else(|| bad(cmd, "invalid selected planar face"))?; + chosen = Some((body, face.index, path.clone(), picked)); + } + } + } + let (body, index, path, plane) = + chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?; + if fixed { + return Ok(PlaneRef::Custom { plane }); + } + let at = apply_point(&author_inverse, point); + let name = solvecraft_doc::naming::face_names(body).get(index).cloned(); + let anchor = match doc.capture_face_anchor(body, index, &plane, &path, s.active_component, &author_path) { + Ok(anchor) => Some(Box::new(anchor)), + Err(solvecraft_doc::DocError::Invalid(message)) + if internal + && matches!( + message.as_str(), + "this face has no exact planar source; choose a fixed plane instead" + | "this face has no supported construction certificate; choose a fixed plane instead" + ) => + { + if path.iter().any(|id| s.pending_moves.contains_key(id) && !author_path.contains(id)) { + return Err(bad(cmd, "capture the source occurrence position before generating a legacy face profile across components")); + } + None + } + Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))), + }; + Ok(PlaneRef::Face { plane, at, name, anchor }) +} + +/// The sole durable face attachment beneath an offset/angle wrapper. +pub(in crate::cmd) fn anchor(mut plane: &PlaneRef) -> Option<&solvecraft_doc::FaceAnchor> { + loop { + match plane { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base, + PlaneRef::Face { anchor, .. } => return anchor.as_deref(), + _ => return None, + } + } +} + +pub(in crate::cmd) fn anchor_mut(mut plane: &mut PlaneRef) -> Option<&mut solvecraft_doc::FaceAnchor> { + loop { + match plane { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base, + PlaneRef::Face { anchor, .. } => return anchor.as_deref_mut(), + _ => return None, + } + } +} + +pub(in crate::cmd) fn capture_positions(s: &mut Session, plane: &PlaneRef) -> Result<()> { + let mut involved = std::collections::BTreeSet::new(); + if let Some(anchor) = anchor(plane) { + involved.extend(anchor.path.iter().copied()); + involved.extend(anchor.author_path.iter().copied()); + } else { + involved.extend(s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component))); + } + let changes: Vec<_> = involved.into_iter().filter_map(|id| s.pending_moves.get(&id).copied().map(|matrix| (id, matrix))).collect(); + for (id, matrix) in &changes { + let occurrence = s + .doc_mut() + .occurrences + .iter_mut() + .find(|occurrence| occurrence.id == *id) + .ok_or_else(|| bad("sketch attachment", "pending author/source path disappeared"))?; + occurrence.transform = *matrix; + } + for (id, _) in changes { + s.pending_moves.remove(&id); + } + Ok(()) +} + +#[cfg(test)] +#[path = "sketch_face_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/sketch_face_tests.rs b/crates/engine/src/cmd/sketch_face_tests.rs new file mode 100644 index 00000000..32ed9218 --- /dev/null +++ b/crates/engine/src/cmd/sketch_face_tests.rs @@ -0,0 +1,401 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}")) +} +fn top_pick(s: &Session, body: &str) -> Value { + let world = s.world_state(); + let body = world.body(body).unwrap(); + let face = body + .body + .faces(0.01) + .unwrap() + .into_iter() + .filter(|face| face.plane_normal.is_some_and(|normal| normal.dot(Vec3::Z) > 0.999)) + .max_by(|a, b| a.centroid.z.total_cmp(&b.centroid.z)) + .unwrap(); + json!({"body":body.name,"index":face.index,"face":[face.centroid.x,face.centroid.y,face.centroid.z]}) +} +fn draw(s: &mut Session) { + run(s, "sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})); + run(s, "sketch.dimension", json!({"entities":["l1"],"value":2})); + run(s, "sketch.finish", json!({})); +} +#[test] +fn exact_selected_base_follows_height_and_never_retargets_control() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Control","name":"ControlFeature"})); + let pick = top_pick(&s, "Base"); + let sketch = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + draw(&mut s); + run(&mut s, "solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new","body_name":"Badge"})); + let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + assert!((s.model.state().sketch(sketch).unwrap().plane.origin.z - 20.).abs() < 1e-6); + assert!((s.world_state().body("Badge").unwrap().mesh().bounds().max.z - 22.).abs() < 1e-6); + run(&mut s, "timeline.delete", json!({"features":["BaseFeature"]})); + assert!(s.model.result(sketch).unwrap().error.is_some()); + assert!(s.model.state().body("Badge").is_none()); + assert!((s.world_state().body("Control").unwrap().mesh().bounds().max.z - 30.).abs() < 1e-6); + let before = s.doc.to_json(); + let reopened = Session::new(solvecraft_doc::Document::from_json(&before).unwrap()); + assert!(reopened.model.result(sketch).unwrap().error.is_some()); +} +#[test] +fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Control"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + draw(&mut s); + run(&mut s, "timeline.delete", json!({"features":["BaseFeature"]})); + let geometry = s.doc.sketch(id).unwrap().clone(); + let before = s.doc.to_json(); + let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch); + let pick = top_pick(&s, "Control"); + let commands = vec![("sketch.redefine".into(), json!({"sketch":id,"plane":pick}))]; + let preview = s.preview_visible_world(&commands).unwrap(); + assert_eq!(s.doc.to_json(), before); + assert!(preview.after.sketch(id).is_some()); + run(&mut s, "sketch.redefine", commands[0].1.clone()); + assert_eq!(s.doc.sketch(id).unwrap(), &geometry); + assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context); + assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + assert!(s.model.result(id).unwrap().error.is_some()); + run(&mut s, "edit.redo", json!({})); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((reopened.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6); +} +#[test] +fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"})); + let mut pick = top_pick(&s, "Base"); + let before = s.doc.to_json(); + let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err(); + assert!(error.to_string().contains("fixed"), "{error}"); + assert_eq!(s.doc.to_json(), before); + pick["fixed"] = json!(true); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + assert!(matches!(s.doc.feature(id).unwrap().kind, solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { .. }, .. })); + assert!(s.doc.sketch(id).unwrap().links.is_empty()); +} +#[test] +fn malformed_exact_face_and_path_leave_every_session_field_unchanged() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})); + let pick = top_pick(&s, "Base"); + for invalid in [ + json!({"body":"Missing","index":pick["index"],"face":pick["face"]}), + json!({"body":"Base","index":-1,"face":pick["face"]}), + json!({"body":"Base","index":pick["index"],"face":pick["face"],"path":[999]}), + json!({"body":"Base","face":pick["face"]}), + ] { + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch); + assert!(s.execute("sketch.create", &json!({"plane":invalid})).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context); + } +} + +#[test] +fn default_certified_borders_follow_width_and_height_with_stable_entities() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":8,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + assert!(s.doc.sketch(id).unwrap().links.iter().any(|link| link.auto_face)); + let old_ids: Vec<_> = s.doc.sketch(id).unwrap().curves.iter().map(|curve| curve.id.clone()).collect(); + run(&mut s, "sketch.finish", json!({})); + let dimensions = s.doc.find_feature("BaseFeature").unwrap().param_names.clone(); + run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"20 mm"})); + run(&mut s, "parameters.change", json!({"name":dimensions[2],"expression":"15 mm"})); + let resolved = s.model.state(); + let sketch = resolved.sketch(id).unwrap(); + assert!((sketch.plane.origin.z - 15.).abs() < 1e-6); + assert!(sketch.sketch.links.iter().all(|link| !link.lost)); + assert_eq!(sketch.sketch.curves.iter().map(|curve| curve.id.clone()).collect::>(), old_ids); + let max_x = sketch.sketch.points.iter().map(|point| point.pos.x).fold(f64::NEG_INFINITY, f64::max); + let min_x = sketch.sketch.points.iter().map(|point| point.pos.x).fold(f64::INFINITY, f64::min); + let max_y = sketch.sketch.points.iter().map(|point| point.pos.y).fold(f64::NEG_INFINITY, f64::max); + let min_y = sketch.sketch.points.iter().map(|point| point.pos.y).fold(f64::INFINITY, f64::min); + assert!(((max_x - min_x) * (max_y - min_y) - 160.).abs() < 1e-5); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost)); +} + +#[test] +fn nested_offset_retains_exact_attachment_during_height_change() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"offset":"3 mm","project_edges":false}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 23.).abs() < 1e-6); +} + +#[test] +fn exact_nested_source_and_cross_owner_pending_paths_capture_only_involved_positions() { + let mut s = Session::default(); + let parent = run(&mut s, "component.create", json!({"name":"Parent","parent":0})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent["occurrence"],"translate":[40,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":parent["component"],"occurrence":second,"path":[second]})); + let child = run(&mut s, "component.create", json!({"name":"Child"})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})); + let child_occ = child["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[10,0,0],"capture":false})); + run(&mut s, "occurrence.move", json!({"occurrence":child_occ,"translate":[0,20,0],"capture":false})); + let unrelated = run(&mut s, "component.create", json!({"name":"Unrelated","parent":0})); + run(&mut s, "occurrence.move", json!({"occurrence":unrelated["occurrence"],"translate":[300,0,0],"capture":false})); + let author = run(&mut s, "component.create", json!({"name":"Author","parent":0})); + run(&mut s, "occurrence.move", json!({"occurrence":author["occurrence"],"translate":[100,0,0],"angle":"90 deg","capture":false})); + let world = s.world_state(); + let key = s + .placed_document() + .body_frames_with_source(&s.model.state()) + .into_iter() + .find(|(_, source, _, path, _)| source == "Base" && *path == vec![second, child_occ]) + .unwrap() + .0; + assert!(world.body(&key).is_some()); + let mut pick = top_pick(&s, &key); + pick["path"] = json!([second, child_occ]); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!("not sketch") }; + let anchor = super::anchor(plane).unwrap(); + assert_eq!(anchor.path, vec![second, child_occ]); + assert_eq!(anchor.author_path, vec![author["occurrence"].as_u64().unwrap()]); + assert_eq!(s.pending_moves.len(), 1); + assert!(s.pending_moves.contains_key(&unrelated["occurrence"].as_u64().unwrap())); + let saved = s.doc.to_json(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + run(&mut s, "edit.redo", json!({})); + let mut reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap()); + assert!(reopened.model.result(id).unwrap().error.is_none()); + run(&mut reopened, "component.activate", json!({"component":0})); + let original = reopened.doc.to_json(); + let undo = reopened.undo.len(); + let dirty = reopened.is_dirty(); + run(&mut reopened, "sketch.edit", json!({"sketch":id})); + assert_eq!(reopened.doc.to_json(), original); + assert_eq!(reopened.undo.len(), undo); + assert_eq!(reopened.is_dirty(), dirty); + assert_eq!(reopened.active_component, author["component"].as_u64().unwrap()); + assert_eq!(reopened.active_occurrence_path, Some(vec![author["occurrence"].as_u64().unwrap()])); +} + +#[test] +fn fixed_rebind_freezes_current_resized_borders_and_only_detaches_auto_links() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":8,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.project", json!({"refs":[{"origin":true}]})); + run(&mut s, "sketch.finish", json!({})); + let length = s.doc.find_feature("BaseFeature").unwrap().param_names[0].clone(); + run(&mut s, "parameters.change", json!({"name":length,"expression":"20 mm"})); + let visible = s.model.state().sketch(id).unwrap().sketch.clone(); + let auto: Vec<_> = visible.links.iter().filter(|link| link.auto_face).map(|link| link.id.clone()).collect(); + let manual: Vec<_> = visible.links.iter().filter(|link| !link.auto_face).map(|link| link.id.clone()).collect(); + let before = s.doc.to_json(); + let mut pick = top_pick(&s, "Base"); + pick["fixed"] = json!(true); + run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick})); + let fixed = s.doc.sketch(id).unwrap(); + assert!(auto.iter().all(|id| fixed.link(id).is_none())); + assert!(manual.iter().all(|id| fixed.link(id).is_some())); + assert_eq!( + fixed.curves.iter().map(|curve| curve.id.clone()).collect::>(), + visible.curves.iter().map(|curve| curve.id.clone()).collect::>() + ); + assert_eq!(fixed.points.iter().map(|point| point.pos).collect::>(), visible.points.iter().map(|point| point.pos).collect::>()); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(id).unwrap().error.is_none()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn strict_anchor_timeline_copy_remaps_producer_and_refuses_external_translation_atomically() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "timeline.copy", json!({"features":["Attached"]})); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let error = s.execute("timeline.paste", &json!({"translate":[30,0,0]})).unwrap_err(); + assert!(error.to_string().contains("external exact face"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + run(&mut s, "timeline.copy", json!({"features":["BaseFeature","Attached"]})); + let pasted = run(&mut s, "timeline.paste", json!({"translate":[30,0,0]})); + assert!(pasted["errors"].as_array().unwrap().is_empty(), "{pasted}"); + let new_id = pasted["pasted"][1]["id"].as_u64().unwrap(); + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(new_id).unwrap().kind else { panic!("not sketch") }; + let anchor = super::anchor(plane).unwrap(); + assert_ne!(anchor.target.origin_feature, s.doc.find_feature("BaseFeature").unwrap().id); + assert_ne!(anchor.target.body, "Base"); + assert!(s.model.state().sketch(new_id).unwrap().sketch.links.iter().all(|link| !link.lost)); + assert!(s.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost)); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(new_id).unwrap().error.is_none()); +} + +#[test] +fn strict_anchor_deep_clone_is_independent_and_rename_preserves_certified_projection() { + let mut s = Session::default(); + let part = run(&mut s, "component.create", json!({"name":"Part","parent":0})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone(); + let pasted = run(&mut s, "component.paste_new", json!({"component":part["component"],"translate":[30,0,0]})); + let owner = pasted["component"].as_u64().unwrap(); + let new_feature = s + .doc + .features + .iter() + .find(|feature| feature.component == owner && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })) + .unwrap(); + let new_id = new_feature.id; + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &new_feature.kind else { panic!("not sketch") }; + let anchor = super::anchor(plane).unwrap(); + let body = anchor.target.body.clone(); + assert_eq!(anchor.component, owner); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 20.).abs() < 1e-6); + assert!((s.model.state().sketch(new_id).unwrap().plane.origin.z - 10.).abs() < 1e-6); + run(&mut s, "body.rename", json!({"body":body,"name":"Independent"})); + assert!(s.model.result(new_id).unwrap().error.is_none()); + assert!(s.model.state().sketch(new_id).unwrap().sketch.links.iter().all(|link| !link.lost)); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(new_id).unwrap().error.is_none()); +} + +#[test] +fn legacy_or_fixed_rebind_needs_explicit_author_path_for_repeated_owner() { + let mut s = Session::default(); + let part = run(&mut s, "component.create", json!({"name":"Part","parent":0})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})); + let mut pick = top_pick(&s, "Base"); + pick["fixed"] = json!(true); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":part["occurrence"],"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":0})); + let pick = top_pick(&s, "Base"); + let before = s.doc.to_json(); + let error = s.execute("sketch.redefine", &json!({"sketch":id,"plane":pick})).unwrap_err(); + assert!(error.to_string().contains("ambiguous"), "{error}"); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick,"author_path":[second]})); + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!("not sketch") }; + assert_eq!(super::anchor(plane).unwrap().author_path, vec![second]); + assert_eq!(s.active_component, 0); +} + +#[test] +fn replacement_producer_with_same_body_name_cannot_heal_lost_anchor() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"Original"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "timeline.suppress", json!({"feature":"Original","suppressed":true})); + run(&mut s, "timeline.rollback", json!({"position":1})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Base","name":"Replacement"})); + run(&mut s, "timeline.rollback", json!({})); + assert!(s.model.state().body("Base").is_some()); + assert!(s.model.result(id).unwrap().error.is_some()); + let before = s.doc.to_json(); + let pick = top_pick(&s, "Base"); + run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick})); + assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + assert!(s.model.result(id).unwrap().error.is_some()); +} + +#[test] +fn freezing_rebuilt_arc_borders_keeps_authored_dimension_entity_indices() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name":"turn","expression":"270 deg","unit":"deg"})); + let profile = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); + let arc = run( + &mut s, + "sketch.draw.dimensioned", + json!({"command":"sketch.arc.center_point","params":{"center":[0,0],"start":[10,0],"sweep":270},"dimensions":{"radius":10,"sweep":"turn"}}), + )["curves"][0] + .as_str() + .unwrap() + .to_owned(); + run(&mut s, "sketch.line", json!({"points":[format!("{arc}.end"),format!("{arc}.start")],"infer":false})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":profile,"distance":10,"operation":"new","body_name":"Sector"})); + let pick = top_pick(&s, "Sector"); + let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap(); + let circle = run(&mut s, "sketch.circle.center", json!({"center":[20,20],"radius":2}))["curves"][0].as_str().unwrap().to_owned(); + run(&mut s, "sketch.dimension", json!({"entities":[circle],"type":"radius","value":"2 mm"})); + run(&mut s, "sketch.finish", json!({})); + let before_count = s.model.state().sketch(id).unwrap().sketch.curves.len(); + run(&mut s, "parameters.change", json!({"name":"turn","expression":"300 deg"})); + assert!(s.model.result(id).unwrap().error.is_none()); + let visible = s.model.state().sketch(id).unwrap().sketch.clone(); + assert_ne!(visible.curves.len(), before_count, "fixture must rebuild projected topology"); + let mut pick = top_pick(&s, "Sector"); + pick["fixed"] = json!(true); + let before = s.doc.to_json(); + run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick})); + let frozen = s.doc.sketch(id).unwrap(); + let circle_index = frozen.curve_index(&circle).unwrap(); + let dimension = frozen + .constraints + .iter() + .find(|constraint| matches!(constraint.kind, solvecraft_sketch::ConstraintKind::Radius { c, .. } if c == circle_index)) + .unwrap(); + assert!((solvecraft_sketch::measure_dimension(frozen, &dimension.kind).unwrap() - 2.).abs() < 1e-6); + assert_eq!(frozen.curves.iter().map(|curve| &curve.id).collect::>(), visible.curves.iter().map(|curve| &curve.id).collect::>()); + assert!(s.model.result(id).unwrap().error.is_none()); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(id).unwrap().error.is_none()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn angled_attachment_tracks_source_plane_and_reopens() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"})); + let pick = top_pick(&s, "Base"); + let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &mut s.doc_mut().feature_mut(id).unwrap().kind else { panic!("not sketch") }; + *plane = solvecraft_doc::PlaneRef::AtAngle { base: Box::new(plane.clone()), axis_origin: Vec3::ZERO, axis_dir: Vec3::X, angle: "90 deg".into() }; + s.refresh(); + let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + let placed = s.model.state().sketch(id).unwrap().plane; + assert!((placed.origin.y + 20.).abs() < 1e-6); + assert!(placed.origin.z.abs() < 1e-6); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(reopened.model.state().sketch(id).unwrap().plane, placed); +} diff --git a/crates/engine/src/cmd/sketch_freeform.rs b/crates/engine/src/cmd/sketch_freeform.rs index 0ec0e92f..699d672e 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 { @@ -147,7 +156,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option, end: Option< Seg2::Line { .. } => CurveKind::Line { a: prev, b: to }, Seg2::Cubic { p1, p2, .. } => { let (c1, c2) = (sk.add_point(p1, None)?, sk.add_point(p2, None)?); - CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3 } + CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false } } Seg2::Conic { apex, w, .. } => { let x = sk.add_point(apex, None)?; @@ -160,7 +169,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option, end: Option< pts.push(sk.add_point(*q, None)?); } pts.push(to); - CurveKind::Spline { pts, control: true, degree: n } + CurveKind::Spline { pts, control: true, degree: n, periodic: false } } Seg2::Arc { .. } => return Err(bad("trim", "unexpected arc piece")), }; @@ -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_ids_tests.rs b/crates/engine/src/cmd/sketch_ids_tests.rs index 1d288385..65063ba0 100644 --- a/crates/engine/src/cmd/sketch_ids_tests.rs +++ b/crates/engine/src/cmd/sketch_ids_tests.rs @@ -112,6 +112,7 @@ fn drawing_commands_return_the_ids_they_create() { ("sketch.line.midpoint", json!({"mid": [0, 0], "end": [10, 5]})), ("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})), ("sketch.spline.fit_point", json!({"points": [[0, 0], [5, 5], [10, 0]]})), + ("sketch.spline.fit_point", json!({"points": [[0, 0], [5, 5], [10, 0]], "periodic": true})), ("sketch.spline.control_point", json!({"points": [[0, 0], [5, 5], [10, 0], [15, 5]]})), ("sketch.spline.control_point_5", json!({"points": [[0, 0], [3, 5], [6, 0], [9, 5], [12, 0], [15, 5]]})), ("sketch.conic", json!({"start": [0, 0], "end": [10, 0], "apex": [5, 6]})), @@ -201,6 +202,37 @@ fn modify_commands_return_the_ids_they_create() { made(&mut s, "sketch.circle.three_tangent", json!({"curves": [x[0], y[0], z[0]], "near": [120, 20]})); } +#[test] +fn dimensioned_drawing_reports_its_geometry_and_driving_dimensions() { + // The creator runs nested inside sketch.draw.dimensioned; `created` must still cover the + // dimensions the wrapper adds afterwards, not just the nested creator's geometry. + for (cmd, p, dims) in [ + ("sketch.line", json!({"points": [[0, 0], [5, 5]]}), json!({"length": 10, "angle": 45})), + ("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}), json!({"width": 30, "height": 15})), + ("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"diameter": 12})), + ("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 10, "sides": 6}), json!({"radius": 8})), + ] { + let mut s = new_sketch(); + let c = made(&mut s, "sketch.draw.dimensioned", json!({"command": cmd, "params": p, "dimensions": dims})); + let n = dims.as_object().map_or(0, |d| d.len()); + assert_eq!(strs(&c["dimensions"]).len(), n, "{cmd}: {c}"); + assert!(!strs(&c["curves"]).is_empty(), "{cmd}: {c}"); + } +} + +#[test] +fn breaking_an_offset_association_creates_nothing() { + let mut s = new_sketch(); + let e = strs(&made(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 5, "connect": false}))["curves"]); + let c = made(&mut s, "sketch.offset", json!({"curves": [e[0]], "distance": -1, "chain": false})); + let out = strs(&c["curves"]); + let r = run(&mut s, "sketch.offset.break", json!({"curve": out[0]})); + let c = &r["created"]; + for k in ["points", "curves", "constraints", "dimensions"] { + assert_eq!(c[k], json!([]), "{r}"); + } +} + #[test] fn text_edits_and_imports_return_the_ids_they_create() { let mut s = new_sketch(); diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 81403fa4..e776852a 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -89,7 +89,7 @@ fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) -> Vec2, k: f6 idx.push(i); } } - (idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree }) + (idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree, periodic: false }) } Geom2::Conic { a, apex, b, rho } => { let (a, x, b) = (pt(sk, *a)?, pt(sk, *apex)?, pt(sk, *b)?); diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index d7b98726..a43dd791 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -9,9 +9,12 @@ 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}; +#[path = "sketch_support.rs"] +mod support; + pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.fillet", "Fillet", fillet) .at("SKETCH", "MODIFY") @@ -39,6 +42,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("O") .enabled(in_sketch) .params(concat!("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)", created_doc!())), + CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset) + .enabled(in_sketch) + .params("curve: derived offset curve id (retains geometry and makes its points editable)"), CommandSpec::new("sketch.trim", "Trim", trim) .at("SKETCH", "MODIFY") .icon("trim") @@ -54,7 +60,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("break") .enabled(in_sketch) - .params(concat!("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]", created_doc!())), + .params(concat!("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())), CommandSpec::new("sketch.scale", "Sketch Scale", scale) .at("SKETCH", "MODIFY") .icon("scale") @@ -89,6 +95,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result { if sk.is_linked_curve(c) { return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first"))); } + if sk.freeform_offsets.iter().any(|offset| offset.output == id) { + return Err(bad(cmd, "break the offset association before editing its output")); + } Ok(c) } @@ -135,6 +144,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 +330,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 { @@ -351,7 +374,12 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result { fn extend(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.extend"; let at = req_vec2(cmd, p, "at")?; - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = edit(s, p, cmd, true, |sk, doc| { + let (values, _) = doc.param_values(); + doc.apply_dimension_values(&values, sk)?; + if !solvecraft_sketch::solve(sk).ok() { + return Err(bad(cmd, "solve the existing sketch constraints before extending")); + } let ci = curve_arg(sk, p, cmd)?; let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?; let (pa, pb) = match sk.curves.get(ci).map(|c| c.kind.clone()) { @@ -372,41 +400,62 @@ fn extend(s: &mut Session, p: &Value) -> Result { }; let (_, t1) = sh.range(); let mut best: Option<(f64, Vec2, usize)> = None; + let mut ambiguous = false; + let mut budget = 1_000_000usize; + let mut segment_count = 0usize; for (oi, _) in sk.curves.iter().enumerate() { if oi == ci { continue; } - // Lines and circles exactly; free-form curves (splines, ellipses, conics) by the - // pieces of their polyline. - let pieces: Vec = match sk.shape(oi) { - Some(other) => vec![other], - None => sk.polyline(oi).windows(2).map(|w| Shape::Line { a: w[0], b: w[1] }).collect(), - }; - for q in pieces.iter().flat_map(|other| intersections(&unbounded, other).into_iter().filter(|q| on_bounded(other, *q).is_some())) { - let t = sh.param(q); - let gap = match sh { - Shape::Line { a, b } => { - let l = a.dist(b); - if at_b { (t - 1.0) * l } else { -t * l } - } - Shape::Round { r, .. } => { - if at_b { - (t - t1) * r - } else { - (TAU - t) * r + let pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi)); + segment_count += pieces.len(); + if segment_count > 16_384 { + return Err(bad(cmd, "too many exact curve pieces to extend safely")); + } + for piece in &pieces { + for q in support::hits(&unbounded, piece, &mut budget)? { + let t = sh.param(q); + let gap = match sh { + Shape::Line { a, b } => { + let l = a.dist(b); + if at_b { (t - 1.0) * l } else { -t * l } + } + Shape::Round { r, .. } => { + // A hit already inside the arc cannot lengthen either end. In + // particular, extending the start must not turn a quarter into an eighth. + if t <= t1 + TOL / r.max(1e-12) || t >= TAU - TOL / r.max(1e-12) { + continue; + } + if at_b { (t - t1) * r } else { (TAU - t) * r } + } + }; + if gap > TOL * 10.0 { + if best.is_none_or(|b| gap < b.0 - TOL) { + best = Some((gap, q, oi)); + ambiguous = false; + } else if best.is_some_and(|b| (gap - b.0).abs() <= TOL && (b.2 != oi || b.1.dist(q) > TOL)) { + ambiguous = true; } } - }; - if gap > TOL * 10.0 && best.is_none_or(|b| gap < b.0) { - best = Some((gap, q, oi)); } } } + if ambiguous { + return Err(bad(cmd, "multiple curves stop the extension at the same distance")); + } let (_, q, oi) = best.ok_or_else(|| bad(cmd, "nothing to extend to"))?; if let Some(pt) = sk.points.get_mut(end) { pt.pos = q; } pin(sk, end, oi)?; + let before = sk.points.iter().map(|p| p.pos).collect::>(); + let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() }; + if !solvecraft_sketch::solve_holding(sk, &hold).ok() + || sk.point(end).is_none_or(|point| point.dist(q) > TOL) + || sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL) + { + return Err(bad(cmd, "the extension conflicts with constraints or cannot preserve its original support")); + } Ok(sk.points.get(end).map(|x| x.id.clone()).unwrap_or_default()) })?; Ok(json!({"moved": out, "sketch": info})) @@ -795,7 +844,14 @@ fn offset(s: &mut Session, p: &Value) -> Result { let d0 = num(p, "distance").filter(|d| d.abs() > 1e-9).ok_or_else(|| bad(cmd, "`distance` must be a non-zero number"))?; let side = p.get("side").and_then(vec2); let use_chain = bool_(p, "chain").unwrap_or(true); - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = edit(s, p, cmd, true, |sk, doc| { + let (values, _) = doc.param_values(); + doc.apply_dimension_values(&values, sk)?; + sk.refresh_offsets()?; + if !solvecraft_sketch::solve(sk).ok() { + return Err(bad(cmd, "solve existing constraints before offsetting")); + } + sk.refresh_offsets()?; let sel: Vec = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::>()?; let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?; let mut made = Vec::new(); @@ -841,6 +897,17 @@ fn offset(s: &mut Session, p: &Value) -> Result { } None => d0, }; + if order.iter().any(|(i, _)| sk.shape(*i).is_none()) { + // A smooth free-form entity is offset from exact pieces. Junctions that + // would require trimming/intersecting independent normal offsets are + // refused rather than joined by a guessed average point. + if order.len() != 1 { + return Err(bad(cmd, "offset smooth free-form curves individually; mixed chain junctions are ambiguous")); + } + let (source, forward) = order[0]; + let output = sk.add_freeform_offset(source, forward, d)?; + return Ok((ids_of(sk, &[output]), cons)); + } let pieces: Vec = order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?; let n = pieces.len(); @@ -903,7 +970,7 @@ fn offset(s: &mut Session, p: &Value) -> Result { idx.push(pb); idx.dedup(); if idx.len() >= 2 { - made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3 }, None)?); + made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false }, None)?); } } Piece::Line(..) => { @@ -933,6 +1000,41 @@ fn offset(s: &mut Session, p: &Value) -> Result { Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info})) } +fn break_offset(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.offset.break"; + let (out, info) = edit(s, p, cmd, true, |sk, doc| { + let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?; + let record = sk + .freeform_offsets + .iter() + .position(|record| record.output == output) + .ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?; + // A parameter edit may have changed the evaluated source without + // rewriting the stored fit points. Break must freeze the displayed, + // current dimensioned geometry, rather than its stale stored predecessor. + let (values, _) = doc.param_values(); + doc.apply_dimension_values(&values, sk)?; + if !solvecraft_sketch::solve(sk).ok() { + return Err(bad(cmd, "cannot break an offset with conflicting source dimensions")); + } + sk.refresh_offsets()?; + if !solvecraft_sketch::solve(sk).ok() { + return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions")); + } + sk.validate_periodic_splines()?; + let ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?; + let indices = sk.curves[ci].kind.point_ids(); + sk.freeform_offsets.remove(record); + for i in indices { + if let Some(point) = sk.points.get_mut(i) { + point.fixed = false; + } + } + Ok(output.to_string()) + })?; + Ok(json!({"curve":out,"sketch":info})) +} + // --------------------------------------------------------------------------------------------- // Copies: mirror, patterns, move, scale diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 51916014..df31c181 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(); @@ -87,6 +183,58 @@ fn trim_circle_makes_an_arc_and_a_profile() { assert!(close(a[0], std::f64::consts::PI * 50.0), "{a:?}"); } +#[test] +fn exact_extend_ellipse_preserves_support_target_and_history() { + for (y, expected_x) in [(3.0, 12.0), (5.0, 20.0)] { + let mut s = new_sketch(); + let ellipse = ids(&run(&mut s, "sketch.ellipse", json!({"center":[20,0],"major":[30,0],"minor_radius":5}))["curves"])[0].clone(); + run(&mut s, "sketch.constraint.fix", json!({"entity":ellipse})); + let line = ids(&run(&mut s, "sketch.line", json!({"points":[[0,y],[5,y]],"connect":false,"infer":false}))["curves"])[0].clone(); + let before = s.doc.to_json(); + let target = sketch(&s).exact_segs(0).unwrap(); + run(&mut s, "sketch.extend", json!({"curve":line,"at":[5,y]})); + let sk = sketch(&s); + let end = sk.point(sk.resolve_point(&format!("{line}.end")).unwrap()).unwrap(); + assert!(end.dist(Vec2::new(expected_x, y)) < 1e-7, "exact ellipse contact {end:?}"); + assert_eq!(sk.exact_segs(0).unwrap(), target); + let persisted = s.doc.to_json(); + let reopened = solvecraft_doc::format::read(&persisted).unwrap(); + assert_eq!(reopened.to_json(), persisted); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), persisted); + } +} + +#[test] +fn exact_extend_arc_rejects_interior_hit_atomically() { + let mut s = new_sketch(); + let arc = ids(&run(&mut s, "sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,10],"connect":false}))["curves"])[0].clone(); + let x = 50.0_f64.sqrt(); + let cutter = ids(&run(&mut s, "sketch.line", json!({"points":[[x,5],[x,9]],"connect":false,"infer":false}))["curves"])[0].clone(); + run(&mut s, "sketch.constraint.fix", json!({"entity":cutter})); + let before = s.doc.to_json(); + let history = s.undo.len(); + assert!(s.execute("sketch.extend", &json!({"curve":arc,"at":[10,0]})).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), history); +} + +#[test] +fn exact_extend_refuses_ambiguous_stop_without_history_change() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points":[[0,0],[5,0]],"connect":false,"infer":false})); + run(&mut s, "sketch.line", json!({"points":[[10,-5],[10,5]],"connect":false,"infer":false})); + run(&mut s, "sketch.line", json!({"points":[[8,-2],[12,2]],"connect":false,"infer":false})); + let before = s.doc.to_json(); + let history = s.undo.len(); + let error = s.execute("sketch.extend", &json!({"curve":"l1","at":[5,0]})).unwrap_err(); + assert!(error.to_string().contains("multiple curves"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), history); +} + #[test] fn fillet_and_chamfers() { let mut s = new_sketch(); @@ -219,13 +367,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(); @@ -471,3 +615,214 @@ fn offset_free_form_curves() { let r = run(&mut s, "sketch.offset", json!({"curves": [e], "distance": 1, "side": [100, 20]})); assert_eq!(ids(&r["curves"]).len(), 1, "{r}"); } + +#[test] +fn certified_offset_follows_dimension_drag_reopen_break_and_undo() { + let mut s = new_sketch(); + let ellipse = ids(&run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5,"connect":false}))["curves"])[0].clone(); + run(&mut s, "sketch.dimension", json!({"entities":[ellipse],"type":"major","value":10})); + let before = s.doc.to_json(); + let out = run(&mut s, "sketch.offset", json!({"curves":[ellipse],"distance":-1,"chain":false})); + let output = out["curves"][0].as_str().unwrap().to_string(); + assert_eq!(sketch(&s).freeform_offsets.len(), 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + let result = run(&mut s, "parameters.change", json!({"name":"d1","expression":12})); + assert_eq!(result["errors"], json!([])); + let verify = |s: &Session| { + let sk = sketch(s); + let source = sk.curve_index(&ellipse).unwrap(); + let (c, m) = match sk.curves[source].kind { + CurveKind::Ellipse { c, m, .. } => (sk.point(c).unwrap(), sk.point(m).unwrap()), + _ => panic!("ellipse"), + }; + let derived = sk.exact_segs(sk.curve_index(&output).unwrap()).unwrap(); + let want = m + (m - c).normalized().unwrap(); + assert!(derived[0].point_at(0.0).dist(want) < 1e-7, "updated offset {want:?}"); + }; + verify(&s); + run(&mut s, "sketch.drag", json!({"entity":ellipse,"from":[0,0],"to":[4,3]})); + verify(&s); + let mut reopened = Session { + doc: std::sync::Arc::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap()), + active_sketch: s.active_sketch, + ..Session::default() + }; + reopened.refresh(); + verify(&reopened); + let attached = reopened.doc.to_json(); + run(&mut reopened, "sketch.offset.break", json!({"curve":output})); + let sk = sketch(&reopened); + assert!(sk.freeform_offsets.is_empty()); + assert!(sk.curves[sk.curve_index(&output).unwrap()].kind.point_ids().iter().all(|i| !sk.points[*i].fixed)); + run(&mut reopened, "edit.undo", json!({})); + assert_eq!(reopened.doc.to_json(), attached); +} + +#[test] +fn certified_offset_delete_output_undo_reopen_and_refuse_dependent_source() { + let mut s = new_sketch(); + run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})); + let out = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false})); + let id = out["curves"][0].as_str().unwrap().to_string(); + let attached = s.doc.to_json(); + let history = s.undo.len(); + assert!(s.execute("sketch.delete", &json!({"entities":["e1"]})).is_err()); + assert_eq!(s.doc.to_json(), attached); + assert_eq!(s.undo.len(), history); + run(&mut s, "sketch.delete", json!({"entities":[id]})); + let sk = sketch(&s); + assert!(sk.freeform_offsets.is_empty()); + assert!(sk.curve_index(&id).is_none()); + assert!(sk.curve_index("e1").is_some()); + let deleted = s.doc.to_json(); + let reopened = solvecraft_doc::format::read(&deleted).unwrap(); + assert_eq!(reopened.to_json(), deleted); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), attached); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), deleted); + // The central removal path also handles a deleted fit node, which removes its curve. + run(&mut s, "edit.undo", json!({})); + let sk = sketch(&s); + let node = sk.points[sk.curves[sk.curve_index(&id).unwrap()].kind.point_ids()[3]].id.clone(); + run(&mut s, "sketch.delete", json!({"entities":[node]})); + assert!(sketch(&s).freeform_offsets.is_empty()); + assert!(sketch(&s).curve_index(&id).is_none()); + assert_eq!(sketch(&s).points.len(), 3, "all private derived nodes must be removed"); +} + +#[test] +fn periodic_closed_offset_chain_break_drag_reopen_and_material_volume() { + let mut s = new_sketch(); + run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})); + run(&mut s, "sketch.dimension", json!({"entities":["e1"],"type":"major","value":10})); + let source = sketch(&s).points.clone(); + let a = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false}))["curves"][0].as_str().unwrap().to_string(); + let before_second = s.doc.to_json(); + let b = run(&mut s, "sketch.offset", json!({"curves":[a],"distance":-0.5,"chain":false}))["curves"][0].as_str().unwrap().to_string(); + assert_eq!(&sketch(&s).points[..source.len()], source.as_slice()); + let check = |session: &Session| { + let sk = sketch(session); + assert_eq!(sk.freeform_offsets.iter().map(|r| r.certificate).collect::>(), [2, 2]); + assert_eq!(solvecraft_sketch::find_profiles(&sk).len(), 3); + for id in [&a, &b] { + assert!(matches!(sk.curves[sk.curve_index(id).unwrap()].kind, CurveKind::Spline { periodic: true, .. })); + } + }; + check(&s); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before_second); + run(&mut s, "edit.redo", json!({})); + check(&s); + run(&mut s, "parameters.change", json!({"name":"d1","expression":12})); + check(&s); + let mut reopened = + Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() }; + reopened.refresh(); + check(&reopened); + let attached = reopened.doc.to_json(); + let exact_before = sketch(&reopened).exact_segs(sketch(&reopened).curve_index(&b).unwrap()).unwrap(); + run(&mut reopened, "sketch.offset.break", json!({"curve":b})); + let sk = sketch(&reopened); + assert_eq!(sk.exact_segs(sk.curve_index(&b).unwrap()).unwrap(), exact_before); + let points = sk.curves[sk.curve_index(&b).unwrap()].kind.point_ids(); + let first = sk.points[points[0]].id.clone(); + let from = sk.points[points[0]].pos; + run(&mut reopened, "sketch.drag", json!({"entity":first,"from":from,"to":[from.x+0.2,from.y+0.1]})); + let moved = sketch(&reopened); + moved.validate_periodic_splines().unwrap(); + let indices = moved.curves[moved.curve_index(&b).unwrap()].kind.point_ids(); + assert_eq!(moved.point(indices[0]), moved.point(*indices.last().unwrap())); + assert!(moved.point(indices[0]).unwrap().dist(from) > 0.1); + assert_eq!(solvecraft_doc::format::read(&reopened.doc.to_json()).unwrap().to_json(), reopened.doc.to_json()); + run(&mut reopened, "edit.undo", json!({})); + run(&mut reopened, "edit.undo", json!({})); + assert_eq!(reopened.doc.to_json(), attached); + // An independent ring area multiplied by height probes actual profile material. + let regions = solvecraft_sketch::find_profiles(&sketch(&s)); + let selected = regions.iter().enumerate().min_by(|(_, x), (_, y)| x.area.total_cmp(&y.area)).unwrap(); + let n = 4096; + let h = std::f64::consts::TAU / n as f64; + let perimeter = (0..=n) + .map(|i| { + let t = i as f64 * h; + (144.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt() + * if i == 0 || i == n { + 1.0 + } else if i % 2 == 0 { + 2.0 + } else { + 4.0 + } + }) + .sum::() + * h + / 3.0; + let analytic_ring = 0.5 * perimeter + 1.25 * std::f64::consts::PI; + assert!((selected.1.area - analytic_ring).abs() < 0.03); + let expected = analytic_ring * 3.0; + let sketch_id = s.active_sketch.unwrap(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":sketch_id,"profiles":[selected.0],"distance":3,"operation":"new"})); + let state = s.world_state(); + let volume = solvecraft_kernel::measure(&state.bodies.last().unwrap().body).unwrap().volume; + assert!((volume - expected).abs() < 0.05, "{volume} vs {expected}"); +} + +#[test] +fn periodic_fit_creator_keeps_legacy_and_refuses_hostile_intent_atomically() { + let mut s = new_sketch(); + let p = json!({"points":[[0,0],[4,0],[3,2],[-1,1]],"periodic":true}); + let id = run(&mut s, "sketch.spline.fit_point", p.clone())["curves"][0].as_str().unwrap().to_string(); + let sk = sketch(&s); + let i = sk.curve_index(&id).unwrap(); + assert!(matches!(sk.curves[i].kind, CurveKind::Spline { periodic: true, .. })); + sk.validate_periodic_splines().unwrap(); + assert_eq!(solvecraft_sketch::find_profiles(&sk).len(), 1); + let before = s.doc.to_json(); + let history = s.undo.len(); + for (cmd, p) in [ + ("sketch.spline.control_point", p), + ("sketch.spline.fit_point", json!({"points":[[0,0],[1,0],[0,0],[1,0]],"periodic":true})), + ("sketch.spline.fit_point", json!({"points":[[0,0],[1,0],[1,1]],"periodic":"yes"})), + ] { + assert!(s.execute(cmd, &p).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), history); + } + let indices = sk.curves[i].kind.point_ids(); + let point = sk.points[indices[1]].id.clone(); + let target = sk.points[indices[0]].pos; + let redo = s.redo.len(); + assert!(s.execute("sketch.drag", &json!({"entity":point,"from":sk.points[indices[1]].pos,"to":target})).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), history); + assert_eq!(s.redo.len(), redo); + let legacy = + run(&mut s, "sketch.spline.fit_point", json!({"points":[[0,0],[4,0],[3,2],[-1,1],[0,0]]}))["curves"][0].as_str().unwrap().to_string(); + assert!(matches!(sketch(&s).curves[sketch(&s).curve_index(&legacy).unwrap()].kind, CurveKind::Spline { periodic: false, .. })); +} + +#[test] +fn analytic_offset_partial_delete_releases_survivors_preserves_source_and_undo() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]})); + let out = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":-1})); + let outputs = ids(&out["curves"]); + assert_eq!(outputs.len(), 4); + let before = s.doc.to_json(); + run(&mut s, "sketch.delete", json!({"entities":[outputs[0]]})); + let sk = sketch(&s); + assert!(sk.offsets.is_empty()); + for id in outputs.iter().skip(1) { + let curve = &sk.curves[sk.curve_index(id).unwrap()]; + assert!(curve.kind.point_ids().iter().all(|i| !sk.points[*i].fixed)); + } + assert!(sk.curve_index("l1").is_some()); + let saved = s.doc.to_json(); + assert_eq!(solvecraft_doc::format::read(&saved).unwrap().to_json(), saved); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); +} diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 74a49ed9..4605b24e 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -127,29 +127,78 @@ fn sketch_plane(doc: &Document, id: u64) -> Result { } } +/// Keep candidate feature edits while overlaying uncommitted occurrence placements. +fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> { + if s.pending_moves.is_empty() { + return std::borrow::Cow::Borrowed(doc); + } + let mut placed = doc.clone(); + for o in &mut placed.occurrences { + if let Some(m) = s.pending_moves.get(&o.id) { + o.transform = *m; + } + } + std::borrow::Cow::Owned(placed) +} + +fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solvecraft_doc::Mat { + if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) } +} + /// Re-resolve the links of a sketch being edited against the model before it, so edits see /// current reference geometry. Returns warnings. pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec { if sk.links.is_empty() { return Vec::new(); } - let Ok(plane) = sketch_plane(doc, id) else { return Vec::new() }; + let doc = placed_candidate(s, doc); + let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() }; let (vals, _) = doc.param_values(); let st = s.model.state_before(id); - solvecraft_doc::project::refresh_links(doc, &vals, &st, &plane, sk) + let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); + let attachment = doc.feature(id).and_then(|feature| match &feature.kind { + solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), + _ => None, + }); + let st = match attachment { + Some(anchor) => solvecraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp), + None => Ok(solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))), + }; + let Ok(st) = st else { + return vec!["lost face attachment; reattach explicitly".into()]; + }; + match attachment { + Some(anchor) => solvecraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor), + None => solvecraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk), + } } /// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id. pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result { - let plane = sketch_plane(doc, id)?; + let doc = placed_candidate(s, doc); + let plane = sketch_plane(&doc, id)?; let (vals, _) = doc.param_values(); let st = s.model.state_before(id); // The model as the sketch's component sees it. let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); - let st = solvecraft_doc::state_in_frame(doc, &st, comp); - let r = resolve(doc, &vals, &st, &plane, kind, &src)?; - let name = if let LinkSource::Face { at, .. } = &r.source { - solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty()) + let attachment = doc.feature(id).and_then(|feature| match &feature.kind { + solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), + _ => None, + }); + let st = match attachment { + Some(anchor) => solvecraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp), + None => Ok(solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))), + }; + let st = st?; + let r = resolve(&doc, &vals, &st, &plane, kind, &src)?; + let name = if let LinkSource::Face { body, at } = &r.source { + st.body(body).and_then(|body| { + if let Some(index) = r.face_index { + solvecraft_doc::naming::face_names(body).get(index).filter(|name| !name.is_empty()).cloned() + } else { + solvecraft_doc::naming::face_name_at_body(body, *at) + } + }) } else { None }; @@ -437,17 +486,53 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result Result> { +/// Private compatibility projection for generated face profiles. Named legacy links follow +/// edits, but cannot provide the construction certificate required by public attachments. +pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result { let mut doc = (*s.doc).clone(); + let plane = sketch_plane(&doc, id)?; + let raw = s.model.state_before(id); + let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0); + let state = solvecraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component)); + let resolved = solvecraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?; + let face_name = state.body(body).and_then(|body| solvecraft_doc::naming::face_names(body).get(index).cloned()); let mut sk = doc.sketch(id)?.clone(); - let l = match add_link(s, &doc, id, &mut sk, LinkKind::Project, LinkSource::Face { body: String::new(), at }) { - Ok(l) => l, - Err(_) => return Ok(None), - }; + let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?; + if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) { + entry.face_name = face_name; + } *doc.sketch_mut(id)? = sk; *s.doc_mut() = doc; - Ok(Some(l)) + Ok(link) +} + +/// Project the edges of the face a new sketch was created on (Fusion's default). +pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result> { + let mut doc = (*s.doc).clone(); + let Some(anchor) = doc + .feature(id) + .and_then(|feature| match &feature.kind { + solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), + _ => None, + }) + .cloned() + else { + return Ok(None); + }; + let raw = s.model.state_before(id); + let author = doc.feature(id).map(|feature| feature.component).unwrap_or(0); + let state = solvecraft_doc::state_in_face_anchor_frame(&doc, &raw, &anchor, author)?; + let plane = doc.resolve_face_anchor(&anchor, author, &raw)?; + let resolved = solvecraft_doc::project::resolve_anchored_face(&doc, &state, &plane, LinkKind::Project, &anchor)?; + let mut sk = doc.sketch(id)?.clone(); + let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?; + if let Some(link) = sk.links.iter_mut().find(|entry| entry.id == link) { + link.face_name = Some(anchor.target.name.clone()); + link.auto_face = true; + } + *doc.sketch_mut(id)? = sk; + *s.doc_mut() = doc; + Ok(Some(link)) } #[cfg(test)] diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index bf9e699a..6dbb6eff 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -13,6 +13,68 @@ fn ids(v: &Value) -> Vec { v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() } +#[test] +fn manual_projection_captures_the_requested_coincident_body_producer() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Control","name":"ControlFeature"})); + run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Target","name":"TargetFeature"})); + let producer = s.doc.find_feature("TargetFeature").unwrap().id; + let id = run(&mut s, "sketch.create", json!({"plane":"XY","offset":10}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.project", json!({"refs":[{"body":"Target","face":[1,1,10]}]})); + run(&mut s, "sketch.finish", json!({})); + let initial_state = s.model.state(); + let initial = &initial_state.sketch(id).unwrap().sketch.links[0]; + assert_eq!(initial.face_name, Some(format!("F{producer}:+z"))); + assert!(!initial.lost); + let height = s.doc.find_feature("TargetFeature").unwrap().param_names[2].clone(); + run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"})); + let verify = |session: &Session| { + let state = session.model.state(); + let sk = &state.sketch(id).unwrap().sketch; + assert!(!sk.links[0].lost); + assert_eq!(sk.links[0].face_name, Some(format!("F{producer}:+z"))); + assert!(matches!(&sk.links[0].source,solvecraft_sketch::LinkSource::Face{body,at} if body=="Target" && (at.z-20.).abs()<1e-6)); + assert!(sk.points.iter().any(|point| point.pos.x > 19.)); + assert!(sk.points.iter().any(|point| point.pos.y > 19.)); + assert!((session.world_state().body("Control").unwrap().mesh().bounds().max.z - 10.).abs() < 1e-6); + }; + verify(&s); + verify(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap())); +} + +#[test] +fn initial_projection_retains_selected_face_when_closest_point_lands_on_shared_edge() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Target"})); + let state = s.model.state(); + let body = state.body("Target").unwrap(); + let source = solvecraft_sketch::LinkSource::Face { body: "Target".into(), at: solvecraft_geom::Vec3::new(-0.1, 5., 10.5) }; + let resolved = solvecraft_doc::project::resolve( + &s.doc, + &std::collections::BTreeMap::new(), + &state, + &solvecraft_geom::Plane::XY, + solvecraft_sketch::LinkKind::Project, + &source, + ) + .unwrap(); + let index = resolved.face_index.unwrap(); + let expected = solvecraft_doc::naming::face_names(body)[index].clone(); + let solvecraft_sketch::LinkSource::Face { at, .. } = resolved.source else { panic!("not face") }; + assert!(at.x.abs() < 1e-6 && (at.z - 10.).abs() < 1e-6, "closestpoint must be on shared top/wall edge: {at:?}"); + let plane = body.body.face_plane(index).unwrap(); + let id = run(&mut s, "sketch.create", json!({"plane":{"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y}}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.project", json!({"refs":[{"body":"Target","face":[-0.1,5.,10.5]}]})); + run(&mut s, "sketch.finish", json!({})); + let current = s.model.state(); + let sk = ¤t.sketch(id).unwrap().sketch; + assert_eq!(sk.links[0].face_name, Some(expected)); + assert!(!sk.links[0].lost); + assert_eq!(sk.curves.len(), 4); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(reopened.model.state().sketch(id).unwrap().sketch.curves, sk.curves); +} + /// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`. fn box_part(s: &mut Session) { run(s, "parameters.change", json!({"name": "width", "expression": "40 mm"})); diff --git a/crates/engine/src/cmd/sketch_support.rs b/crates/engine/src/cmd/sketch_support.rs new file mode 100644 index 00000000..28498d61 --- /dev/null +++ b/crates/engine/src/cmd/sketch_support.rs @@ -0,0 +1,214 @@ +//! Bounded roots on exact curve pieces against an infinite line or complete circle. +//! Rational pieces are intersected in homogeneous coordinates, without sampled chords. +use solvecraft_geom::{Seg2, Vec2}; +use solvecraft_sketch::{Shape, intersections}; + +use crate::{Result, params::bad}; + +fn choose(n: usize, k: usize) -> f64 { + (0..k.min(n - k)).fold(1.0, |value, i| value * (n - i) as f64 / (i + 1) as f64) +} +fn product(a: &[f64], b: &[f64]) -> Vec { + let (n, m) = (a.len() - 1, b.len() - 1); + (0..=n + m) + .map(|k| (k.saturating_sub(m)..=k.min(n)).map(|i| a[i] * b[k - i] * choose(n, i) * choose(m, k - i) / choose(n + m, k)).sum()) + .collect() +} +fn evaluate(c: &[f64], t: f64) -> f64 { + let mut values = [0.0; 15]; + for (slot, value) in values.iter_mut().zip(c) { + *slot = *value; + } + for n in (1..c.len()).rev() { + for i in 0..n { + values[i] = values[i] * (1.0 - t) + values[i + 1] * t; + } + } + values[0] +} + +/// Derivative roots partition a polynomial into monotone intervals. Testing its critical +/// points retains repeated/tangent roots that a sign-change-only search would miss. +fn roots(c: &[f64], budget: &mut usize) -> Result> { + if c.is_empty() || c.len() > 15 || c.iter().any(|v| !v.is_finite()) { + return Err(bad("sketch.extend", "invalid intersection polynomial")); + } + let scale = c.iter().map(|v| v.abs()).fold(0.0_f64, f64::max); + if scale < 1e-28 { + return Ok(Vec::new()); + } + let eps = scale * 2e-13; + if c.len() == 1 { + return Ok(Vec::new()); + } + let d: Vec = c.windows(2).map(|w| (w[1] - w[0]) * (c.len() - 1) as f64).collect(); + let mut knots = roots(&d, budget)?; + knots.extend([0.0, 1.0]); + knots.sort_by(f64::total_cmp); + knots.dedup_by(|a, b| (*a - *b).abs() < 1e-13); + let mut found: Vec = knots.iter().copied().filter(|t| evaluate(c, *t).abs() <= eps).collect(); + for pair in knots.windows(2) { + let (mut lo, mut hi) = (pair[0], pair[1]); + let (mut fl, fh) = (evaluate(c, lo), evaluate(c, hi)); + if fl.abs() <= eps || fh.abs() <= eps || fl.signum() == fh.signum() { + continue; + } + for _ in 0..64 { + if *budget == 0 { + return Err(bad("sketch.extend", "intersection work limit exceeded")); + } + *budget -= 1; + let mid = (lo + hi) * 0.5; + let f = evaluate(c, mid); + if hi - lo < 2e-14 { + break; + } + if f.signum() == fl.signum() { + lo = mid; + fl = f; + } else { + hi = mid; + } + } + found.push((lo + hi) * 0.5); + } + found.sort_by(f64::total_cmp); + found.dedup_by(|a, b| (*a - *b).abs() < 1e-10); + Ok(found) +} + +pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result> { + if let Seg2::Line { a, b } = *seg { + let other = Shape::Line { a, b }; + if let Shape::Line { a: pa, b: pb } = *support + && (pb - pa).normalized().is_some_and(|d| d.cross(a - pa).abs() < 1e-9 && d.cross(b - pa).abs() < 1e-9) + { + return Err(bad("sketch.extend", "the continuation overlaps another curve")); + } + return Ok(intersections(support, &other) + .into_iter() + .filter(|p| { + let d = b - a; + let t = (*p - a).dot(d) / d.len2().max(1e-30); + (-1e-10..=1.0 + 1e-10).contains(&t) + }) + .collect()); + } + if let Seg2::Arc { center, radius, start, sweep } = *seg { + let other = Shape::Round { c: center, r: radius, start, sweep }; + if let Shape::Round { c, r, .. } = *support + && c.dist(center) < 1e-9 + && (r - radius).abs() < 1e-9 + { + return Err(bad("sketch.extend", "the continuation overlaps another curve")); + } + return Ok(intersections(support, &other) + .into_iter() + .filter(|p| { + let t = (((*p - center).angle() - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU); + t <= sweep.abs() + 1e-10 + }) + .collect()); + } + let (points, weights): (Vec, Vec) = match *seg { + Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), + Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), + Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + _ => return Err(bad("sketch.extend", "invalid exact curve piece")), + }; + if points.iter().any(|p| !p.is_finite()) { + return Err(bad("sketch.extend", "nonfinite intersection geometry")); + } + let weight_scale = weights.iter().copied().fold(1.0_f64, f64::max); + let weights: Vec = weights.iter().map(|w| w / weight_scale).collect(); + let origin = match *support { + Shape::Line { a, .. } => a, + Shape::Round { c, .. } => c, + }; + let scale = points.iter().map(|p| p.dist(origin)).fold(1.0_f64, f64::max); + let x: Vec = points.iter().zip(&weights).map(|(p, w)| (p.x - origin.x) * w / scale).collect(); + let y: Vec = points.iter().zip(&weights).map(|(p, w)| (p.y - origin.y) * w / scale).collect(); + let c = match *support { + Shape::Line { a, b } => { + let d = (b - a).normalized().ok_or_else(|| bad("sketch.extend", "zero-length support"))?; + x.iter().zip(&y).map(|(x, y)| d.x * y - d.y * x).collect::>() + } + Shape::Round { r, .. } => { + let (xx, yy, dd) = (product(&x, &x), product(&y, &y), product(&weights, &weights)); + xx.iter().zip(&yy).zip(&dd).map(|((x, y), d)| x + y - (r / scale).powi(2) * d).collect() + } + }; + if c.iter().all(|v| v.abs() < 1e-28) { + return Err(bad("sketch.extend", "the continuation overlaps another curve")); + } + let mut out = Vec::new(); + for t in roots(&c, budget)? { + let p = seg.point_at(t); + if !p.is_finite() || support.project(p).dist(p) > 1e-7 { + return Err(bad("sketch.extend", "the intersection could not be resolved accurately")); + } + if !out.iter().any(|q: &Vec2| q.dist(p) < 1e-7) { + out.push(p); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_extend_negative_arc_target() { + let support = Shape::Line { a: Vec2::new(-2.0, 0.5), b: Vec2::new(-1.5, 0.5) }; + let target = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: std::f64::consts::PI, sweep: -std::f64::consts::FRAC_PI_2 }; + let found = hits(&support, &target, &mut 1000).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].dist(Vec2::new(-0.75_f64.sqrt(), 0.5)) < 1e-10); + } + + #[test] + fn exact_extend_degree_seven_retains_tangent_and_crossing() { + // Degree elevation of quadratic y=(2t-1)^2 retains its double root at t=1/2. + let mut y = vec![1.0, -1.0, 1.0]; + while y.len() < 8 { + let n = y.len(); + let mut elevated = vec![y[0]]; + for i in 1..n { + let a = i as f64 / n as f64; + elevated.push(a * y[i - 1] + (1.0 - a) * y[i]); + } + elevated.push(y[n - 1]); + y = elevated; + } + let mut points = [Vec2::ZERO; 8]; + for (i, p) in points.iter_mut().enumerate() { + *p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]); + } + let target = Seg2::Bezier { n: 7, p: points }; + let support = Shape::Line { a: Vec2::ZERO, b: Vec2::X }; + let found = hits(&support, &target, &mut 10000).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].dist(Vec2::new(20.0, 0.0)) < 1e-7); + let crossing = Shape::Line { a: Vec2::new(0.0, 0.25), b: Vec2::new(1.0, 0.25) }; + let found = hits(&crossing, &target, &mut 10000).unwrap(); + assert_eq!(found.len(), 2); + assert!(found[0].dist(Vec2::new(15.0, 0.25)) < 1e-7); + assert!(found[1].dist(Vec2::new(25.0, 0.25)) < 1e-7); + assert!(hits(&crossing, &target, &mut 0).is_err()); + let circle = Shape::Round { c: Vec2::new(20.0, -1.0), r: 1.0, start: 0.0, sweep: std::f64::consts::TAU }; + let found = hits(&circle, &target, &mut 10000).unwrap(); + assert_eq!(found.len(), 1, "degree fourteen tangent {found:?}"); + assert!(found[0].dist(Vec2::new(20.0, 0.0)) < 1e-7); + } + + #[test] + fn exact_extend_rational_quarter_hits_analytic_eight_six_point() { + let target = + Seg2::Conic { a: Vec2::new(10.0, 0.0), apex: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0), w: std::f64::consts::FRAC_1_SQRT_2 }; + let support = Shape::Line { a: Vec2::new(0.0, 6.0), b: Vec2::new(1.0, 6.0) }; + let found = hits(&support, &target, &mut 10000).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].dist(Vec2::new(8.0, 6.0)) < 1e-7); + } +} diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index dd0ab8fe..1698f65f 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -26,6 +26,8 @@ enum K { Make, /// A direction: turned with the command's frame. Dir, + /// An arbitrary axis object in the authoring frame: {origin, dir}. + Axis, /// A plane object (`{face}`, `{origin, normal}`, `{origin, x_dir, y_dir}`) or a plane name. Plane, /// A body name, or a list of them. @@ -43,7 +45,8 @@ use K::*; /// The parameters each command takes in world coordinates. fn table(id: &str) -> &'static [(&'static str, K)] { match id { - "solid.extrude" | "solid.revolve" => &[("face", Pick), ("targets", Body), ("participants", Body)], + "solid.extrude" => &[("face", Pick), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)], + "solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)], "solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)], "solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)], "solid.fillet" | "solid.chamfer" => &[("edges", Picks), ("body", Body)], @@ -51,6 +54,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] { "solid.scale" => &[("origin", Make), ("bodies", Body)], "solid.offset_face" | "solid.shell" => &[("faces", Picks), ("body", Body)], "solid.thread" => &[("face", Pick)], + "solid.emboss" => &[("face", Make)], "solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)], "solid.press_pull" => &[("face", Pick), ("edges", Picks)], "solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)], @@ -88,38 +92,20 @@ struct Frames { fn frames(s: &Session) -> Frames { let st = s.model.state(); - let doc: std::borrow::Cow = if s.pending_moves.is_empty() { - std::borrow::Cow::Borrowed(&*s.doc) - } else { - let mut d = (*s.doc).clone(); - for o in &mut d.occurrences { - if let Some(m) = s.pending_moves.get(&o.id) { - o.transform = *m; - } - } - std::borrow::Cow::Owned(d) - }; + let doc = s.placed_document(); // World name, the component's own body name, and its frame. - let bodies = doc - .body_frames(&st) - .into_iter() - .map(|(world, _, _, m)| { - let own = st - .bodies - .iter() - .find(|b| world == b.name || world.starts_with(&format!("{} (", b.name))) - .map(|b| b.name.clone()) - .unwrap_or_else(|| world.clone()); - (world, own, m) - }) - .collect(); + let bodies = doc.body_frames_with_source(&st).into_iter().map(|(world, own, _, _, m)| (world, own, m)).collect(); let sketch_comp = s.active_sketch.and_then(|id| doc.feature(id)).map(|f| f.component).unwrap_or(s.active_component); - Frames { bodies, active: doc.component_transform(s.active_component), sketch: doc.component_transform(sketch_comp) } + let active = s.active_component_frame(); + Frames { bodies, active, sketch: if sketch_comp == s.active_component { active } else { doc.component_transform(sketch_comp) } } } -/// Does any placement move anything (so the world differs from the components' frames)? +/// Does world geometry or its instance naming differ from source components? pub fn placed(s: &Session) -> bool { - !s.pending_moves.is_empty() || !s.doc.body_offsets.is_empty() || s.doc.placements().iter().any(|(_, _, m)| !is_identity(m)) + let mut components = std::collections::BTreeSet::new(); + !s.pending_moves.is_empty() + || !s.doc.body_offsets.is_empty() + || s.doc.occurrences.iter().any(|o| !is_identity(&o.transform) || !components.insert(o.component)) } fn vec3(v: &Value) -> Option { @@ -138,7 +124,7 @@ fn pt(p: Vec3) -> Value { /// The frame of the body a world point lies on (the nearest), or of the named body. fn frame_at(s: &Session, f: &Frames, p: Vec3, body: Option<&str>) -> Option { if let Some(b) = body - && let Some((_, _, m)) = f.bodies.iter().find(|(w, own, _)| w == b || (own == b && !w.contains(" ("))) + && let Some((_, _, m)) = f.bodies.iter().find(|(w, _, _)| w == b) { return Some(*m); } @@ -345,17 +331,63 @@ 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; + if let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &feature.kind { + context.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone()); + } + let mut inner = to_local(&context, cmd, params.get("params")?)?; + // An untouched explicit axis is displayed in world coordinates by Edit + // Feature. Keep its exact saved values instead of an affine round trip. + if cmd == "solid.revolve" + && let solvecraft_doc::FeatureKind::Revolve { axis: solvecraft_doc::AxisRef::Line { origin, dir }, .. } = &feature.kind + { + let frame = context.active_component_frame(); + let displayed = serde_json::json!({"origin":pt(apply_point(&frame,*origin)),"dir":pt(apply_vector(&frame,*dir))}); + if params.get("params").and_then(|p| p.get("axis")) == Some(&displayed) { + inner["axis"] = serde_json::json!({"origin":pt(*origin),"dir":pt(*dir)}); + } + } + let points = match (&feature.kind, cmd) { + (solvecraft_doc::FeatureKind::Revolve { to, .. }, "solid.revolve") => [("to", *to), ("from", None)], + (solvecraft_doc::FeatureKind::Extrude { extent, .. }, "solid.extrude") => [("to", extent.to), ("from", extent.from)], + _ => [("to", None), ("from", None)], + }; + for (key, point) in points { + if let Some(point) = point { + let displayed = pt(apply_point(&context.active_component_frame(), point)); + if params.get("params").and_then(|p| p.get(key)) == Some(&displayed) { + inner[key] = pt(point); + } + } + } let mut out = params.clone(); out["params"] = inner; return Some(out); } + // Exact face capture retains the original world pick and occurrence identity. + if matches!(id, "sketch.create" | "sketch.redefine") && params.get("plane").and_then(|plane| plane.get("face")).is_some() { + return None; + } let keys = table(id); if keys.is_empty() || !placed(s) { return None; } let obj = params.as_object()?; - let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false }; + let mut command_frames = frames(s); + if id == "sketch.redefine" { + let key = params.get("sketch").and_then(|value| value.as_str().map(str::to_string).or_else(|| value.as_u64().map(|id| id.to_string())))?; + let feature = s.doc.find_feature(&key)?; + let path = crate::cmd::author_path_for_rebind(s, feature, params, id).ok()?; + let frame = solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), feature.component, &path).ok()?; + command_frames.active = frame; + command_frames.sketch = frame; + } + let mut m = Mapper { s, f: command_frames, first: None, changed: false, here: false }; let body = obj.get("body").and_then(Value::as_str).map(str::to_string); let mut out: Map = obj.clone(); // Picks first: their frame turns the directions. @@ -371,6 +403,24 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { Picks => m.picks(v, body.as_deref()), Make => m.make(v), Dir => m.dir(v), + Axis => { + if let Some(axis) = v.as_object() { + let mut axis = axis.clone(); + if let Some(origin) = axis.get("origin").and_then(|o| m.make(o)) { + axis.insert("origin".into(), origin); + } + if let Some(dir) = axis.get("dir").and_then(vec3) + && !is_identity(&m.f.active) + && let Some(inv) = mat_inverse(&m.f.active) + { + axis.insert("dir".into(), pt(apply_vector(&inv, dir))); + m.changed = true; + } + Some(Value::Object(axis)) + } else { + None + } + } Plane => m.plane(v), Body => m.body(v), Here | HerePlane => { diff --git a/crates/engine/src/frames_tests.rs b/crates/engine/src/frames_tests.rs index 751efdf1..f1eec120 100644 --- a/crates/engine/src/frames_tests.rs +++ b/crates/engine/src/frames_tests.rs @@ -75,12 +75,17 @@ fn world_picks_map_into_the_component() { // The edge along X at the top front: world (65, 0, 10). run(&mut s, "solid.fillet", json!({"edges": [[65, 0, 10]], "radius": 2})); 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})); + // The modified face has no certified lineage; explicitly freeze its picked plane. + // Its world pick still maps into the moved component exactly once. + run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10],"fixed":true}, "name": "Pad"})); + 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/engine/src/lib.rs b/crates/engine/src/lib.rs index 540f8ffb..1d157477 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod frames; pub mod fuzz; pub mod legacy_ids; pub mod licences; +pub mod occurrences; pub mod params; pub mod recovery; pub mod sample; @@ -74,6 +75,10 @@ pub type Result = std::result::Result; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Sel { + /// An exact placement, including its containing occurrence placements. + Occurrence { + path: Vec, + }, Body { name: String, }, @@ -128,6 +133,7 @@ pub struct Snapshot { pub label: String, pub doc: Arc, pub active_sketch: Option, + pub pending_moves: std::collections::BTreeMap, } /// What some commands would make, evaluated on a scratch copy of a session @@ -159,9 +165,12 @@ pub struct Session { pub auto_project: bool, /// The component new sketches and features go into (0 = the root). pub active_component: u64, + /// The placement used when authoring shared component geometry. + pub active_occurrence_path: Option>, /// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them). pub pending_moves: std::collections::BTreeMap, world_cache: std::sync::Mutex)>>, + occurrence_visibility_cache: std::sync::Mutex>, /// Features copied with `timeline.copy` (pasted by `timeline.paste`). pub clipboard: Vec, /// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden). @@ -176,12 +185,13 @@ pub struct Session { next_checkpoint: u64, } -/// The design, active sketch and undo history at an `edit.checkpoint`. +/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`. #[derive(Clone, Debug)] pub(crate) struct Checkpoint { pub id: u64, pub doc: Arc, pub active_sketch: Option, + pub pending_moves: std::collections::BTreeMap, pub undo: Vec, pub redo: Vec, } @@ -192,6 +202,8 @@ pub(crate) struct Checkpoint { pub struct Visibility { /// Bodies by name. pub hidden_bodies: Vec, + /// Exact occurrence placements hidden with all descendants. + pub hidden_occurrences: Vec>, /// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name. pub hidden_origin: Vec, /// Sketches by feature id. @@ -246,8 +258,10 @@ impl Session { revision: 1, auto_project: true, active_component: 0, + active_occurrence_path: None, pending_moves: Default::default(), world_cache: Default::default(), + occurrence_visibility_cache: Default::default(), clipboard: Vec::new(), section: None, analysis: None, @@ -295,7 +309,7 @@ impl Session { { return w.clone(); } - let w = if self.pending_moves.is_empty() { + let mut w = if self.pending_moves.is_empty() { solvecraft_doc::world_state(&self.doc, &st) } else { let mut d = (*self.doc).clone(); @@ -306,6 +320,30 @@ impl Session { } solvecraft_doc::world_state(&d, &st) }; + // Sketch definitions are displayed once, at the placement currently being authored. + if self.active_occurrence_path.is_some() && self.active_component != 0 { + let first = self.occurrence_placement(&self.doc.first_path(self.active_component)).map(|p| p.world).unwrap_or(solvecraft_doc::IDENTITY); + if let Some(inverse) = solvecraft_doc::mat_inverse(&first) { + let delta = solvecraft_doc::mat_mul(&self.active_component_frame(), &inverse); + for sketch in w.sketches.iter_mut().filter(|sk| self.doc.feature(sk.feature).is_some_and(|f| f.component == self.active_component)) { + let plane = sketch.plane; + if let Some(mapped) = solvecraft_geom::Plane::new( + solvecraft_doc::apply_point(&delta, plane.origin), + solvecraft_doc::apply_vector(&delta, plane.x), + solvecraft_doc::apply_vector(&delta, plane.y), + ) { + sketch.plane = mapped; + } + for wire in &mut sketch.sketch.wires { + if wire.space == solvecraft_doc::sketch::WireSpace::Component { + for point in wire.pts.iter_mut().chain(&mut wire.fit) { + *point = solvecraft_doc::apply_point(&delta, *point); + } + } + } + } + } + } let w = Arc::new(w); if let Ok(mut c) = self.world_cache.lock() { *c = Some((self.revision, w.clone())); @@ -316,6 +354,7 @@ impl Session { pub fn refresh(&mut self) { self.model.evaluate(&self.doc); self.revision += 1; + self.prune_occurrence_state(); // Joints follow the geometry their origins snap to. if !self.doc.assembly.joints.is_empty() { cmd::joints::resolve(self); @@ -332,7 +371,12 @@ impl Session { pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; (spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; - let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch }; + let before = Snapshot { + label: spec.label.to_string(), + doc: self.doc.clone(), + active_sketch: self.active_sketch, + pending_moves: self.pending_moves.clone(), + }; let null = Value::Object(Default::default()); let params = if params.is_null() { &null } else { params }; // Picks in the world go into their component's frame (once: commands run by commands @@ -365,9 +409,10 @@ impl Session { Ok(v) } Err(e) => { - if changed { + if changed || self.pending_moves != before.pending_moves { self.doc = before.doc; self.active_sketch = before.active_sketch; + self.pending_moves = before.pending_moves; self.refresh(); } Err(e) @@ -377,15 +422,14 @@ impl Session { /// A sketch command's result gets `created`: the ids of the points, curves, constraints and /// dimensions it added to its sketch (the `sketch` param, else the active sketch), so every - /// drawing and modify command reports them the same way (see `cmd::sketch_created`). + /// drawing and modify command reports them the same way (see `cmd::sketch_created`). A command + /// that runs another one (`sketch.draw.dimensioned`) gets the whole diff, replacing the nested + /// command's narrower `created`. fn with_created(&self, id: &str, params: &Value, before: &Snapshot, mut v: Value) -> Value { if !id.starts_with("sketch.") { return v; } let Some(out) = v.as_object_mut() else { return v }; - if out.contains_key("created") { - return v; - } let named = match params.get("sketch") { Some(Value::Number(n)) => self.doc.find_feature(&n.to_string()).map(|f| f.id), Some(Value::String(x)) => self.doc.find_feature(x).map(|f| f.id), @@ -397,7 +441,7 @@ impl Session { v } - /// Remember the design, active sketch and undo history; returns the checkpoint's id. + /// Remember the design, active sketch, pending positions and undo history; returns the checkpoint's id. pub fn checkpoint(&mut self) -> u64 { let id = self.next_checkpoint; self.next_checkpoint += 1; @@ -406,6 +450,7 @@ impl Session { doc: self.doc.clone(), active_sketch: self.active_sketch, undo: self.undo.clone(), + pending_moves: self.pending_moves.clone(), redo: self.redo.clone(), }); if self.checkpoints.len() > MAX_CHECKPOINTS { @@ -421,6 +466,7 @@ impl Session { let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) }; self.doc = c.doc; self.active_sketch = c.active_sketch; + self.pending_moves = c.pending_moves; self.undo = c.undo; self.redo = c.redo; self.selection.clear(); @@ -444,21 +490,23 @@ impl Session { undo: Vec::new(), redo: Vec::new(), active_sketch: self.active_sketch, - selection: Vec::new(), + selection: self.selection.clone(), path: None, saved: self.saved.clone(), log: Vec::new(), revision: self.revision, auto_project: self.auto_project, active_component: self.active_component, + active_occurrence_path: self.active_occurrence_path.clone(), pending_moves: self.pending_moves.clone(), world_cache: Default::default(), + occurrence_visibility_cache: Default::default(), clipboard: self.clipboard.clone(), section: None, analysis: None, checkpoints: Vec::new(), next_checkpoint: 1, - visibility: Visibility::default(), + visibility: self.visibility.clone(), } } @@ -492,6 +540,7 @@ impl Session { self.revision = computed.revision; self.invalidate_view(previous_revision); self.active_component = computed.active_component; + self.active_occurrence_path = computed.active_occurrence_path; self.pending_moves = computed.pending_moves; for message in computed.log { self.echo(message); @@ -503,10 +552,26 @@ impl Session { /// A `timeline.redefine` is shown even while the timeline is rolled back to the feature /// being edited. pub fn preview(&self, commands: &[(String, Value)]) -> Result { - self.scratch().preview_in_place(commands).map(|after| Preview { before: self.model.state(), after }) + self.scratch().preview_in_place(commands).map(|after| Preview { before: self.model.state(), after: after.model.state() }) } - fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result> { + /// The same evaluated preview in occurrence coordinates, including uncaptured moves. + pub fn preview_world(&self, commands: &[(String, Value)]) -> Result { + self.scratch().preview_in_place(commands).map(|after| Preview { before: self.world_state(), after: after.world_state() }) + } + + /// World preview with each session's visibility applied, including new body placements. + pub fn preview_visible_world(&self, commands: &[(String, Value)]) -> Result { + let visible = |session: &Session| { + let world = session.world_state(); + let mut shown = (*world).clone(); + shown.bodies.retain(|body| session.body_visible(&body.name)); + Arc::new(shown) + }; + self.scratch().preview_in_place(commands).map(|after| Preview { before: visible(self), after: visible(&after) }) + } + + fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result { if commands.is_empty() { return Err(EngineError::Other("nothing to preview".into())); } @@ -524,7 +589,7 @@ impl Session { } } } - Ok(self.model.state()) + Ok(self) } /// Measure one or two selections (see `inspect.measure` with `items`); errors come back as diff --git a/crates/engine/src/occurrences.rs b/crates/engine/src/occurrences.rs new file mode 100644 index 00000000..70a59c3d --- /dev/null +++ b/crates/engine/src/occurrences.rs @@ -0,0 +1,216 @@ +//! Exact placement identity and session view/authoring state. +use crate::{EngineError, Result, Sel, Session}; +use solvecraft_doc::{IDENTITY, Mat, mat_mul}; + +#[derive(Clone, Debug)] +pub struct OccurrenceBody { + pub source: String, + pub world: String, +} + +#[derive(Clone, Debug)] +pub struct OccurrencePlacement { + pub occurrence: u64, + pub component: u64, + pub path: Vec, + pub name: String, + pub world: Mat, + pub local: Mat, + pub parent_world: Mat, + pub grounded: bool, + pub bodies: Vec, +} + +fn invalid(message: &str) -> EngineError { + EngineError::Other(message.to_string()) +} + +pub(crate) struct VisibilityCache { + revision: u64, + paths: Vec>, + bodies: std::collections::BTreeSet, +} + +impl Session { + /// Document with uncaptured occurrence positions applied, without changing saved geometry. + pub fn placed_document(&self) -> std::borrow::Cow<'_, solvecraft_doc::Document> { + if self.pending_moves.is_empty() { + return std::borrow::Cow::Borrowed(&self.doc); + } + let mut doc = (*self.doc).clone(); + for o in &mut doc.occurrences { + if let Some(transform) = self.pending_moves.get(&o.id) { + o.transform = *transform; + } + } + std::borrow::Cow::Owned(doc) + } + /// Resolve only the supplied path, never an exponentially expanded assembly. + pub fn occurrence_placement(&self, path: &[u64]) -> Result { + if path.is_empty() || path.len() > 64 { + return Err(invalid("occurrence path must contain 1..64 ids")); + } + let mut component = 0; + let mut world = IDENTITY; + let mut parent_world = IDENTITY; + let mut seen = std::collections::BTreeSet::new(); + for id in path { + if !seen.insert(*id) { + return Err(invalid("occurrence path repeats an id")); + } + let o = self + .doc + .occurrences + .iter() + .find(|o| o.id == *id && o.parent == component) + .ok_or_else(|| invalid("occurrence path has a missing or mismatched parent"))?; + if !self.doc.components.iter().any(|c| c.id == o.component) { + return Err(invalid("occurrence path has a missing component")); + } + parent_world = world; + world = mat_mul(&world, self.pending_moves.get(id).unwrap_or(&o.transform)); + if world.iter().flatten().any(|x| !x.is_finite()) { + return Err(invalid("occurrence path has a non-finite transform")); + } + component = o.component; + } + let leaf = self.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).ok_or_else(|| invalid("occurrence path has no leaf"))?; + let state = self.model.state(); + let bodies = state + .bodies + .iter() + .filter(|b| self.doc.model_body_component(b) == component) + .map(|b| OccurrenceBody { source: b.name.clone(), world: self.doc.world_body_name(&state, &b.name, component, path) }) + .collect(); + Ok(OccurrencePlacement { + occurrence: leaf.id, + component, + path: path.to_vec(), + name: leaf.name.clone(), + world, + local: self.pending_moves.get(&leaf.id).copied().unwrap_or(leaf.transform), + parent_world, + grounded: leaf.grounded, + bodies, + }) + } + + /// Children are definitions shared by every placement of the parent component. + pub fn occurrence_children(&self, path: &[u64]) -> Result> { + let component = if path.is_empty() { 0 } else { self.occurrence_placement(path)?.component }; + self.doc + .occurrences + .iter() + .filter(|o| o.parent == component) + .map(|o| { + let mut child = path.to_vec(); + child.push(o.id); + self.occurrence_placement(&child) + }) + .collect() + } + + /// Bounded descendant expansion. Never return an incomplete selection or bounds list. + pub fn occurrence_bodies(&self, path: &[u64], descendants: bool) -> Result> { + let mut stack = vec![self.occurrence_placement(path)?]; + let mut out = Vec::new(); + let mut count = 0; + while let Some(placement) = stack.pop() { + count += 1; + if count > 100_000 || out.len() + placement.bodies.len() > 100_000 { + return Err(invalid("occurrence expansion exceeds 100000 placements or bodies")); + } + out.extend(placement.bodies.into_iter().map(|b| b.world)); + if descendants { + stack.extend(self.occurrence_children(&placement.path)?); + } + } + Ok(out) + } + + /// Selected authoring placement, or the compatible first-placement frame. + pub fn active_component_frame(&self) -> Mat { + if let Some(path) = &self.active_occurrence_path + && let Ok(p) = self.occurrence_placement(path) + && p.component == self.active_component + { + return p.world; + } + let path = self.doc.first_path(self.active_component); + self.occurrence_placement(&path).map(|p| p.world).unwrap_or(IDENTITY) + } + + pub fn occurrence_visible(&self, path: &[u64]) -> bool { + !self.visibility.hidden_occurrences.iter().any(|hidden| path.starts_with(hidden)) + } + + pub fn occurrence_hidden(&self, path: &[u64]) -> bool { + self.visibility.hidden_occurrences.iter().any(|hidden| hidden == path) + } + + /// Number of placements affected by editing this shared occurrence definition. + pub fn occurrence_scope_count(&self, path: &[u64]) -> Result { + let id = self.occurrence_placement(path)?.occurrence; + self.occurrence_id_scope_count(id) + } + + pub fn occurrence_id_scope_count(&self, id: u64) -> Result { + if !self.doc.occurrences.iter().any(|o| o.id == id) { + return Err(invalid("no occurrence")); + } + let mut stack = vec![(0, 0usize)]; + let mut visited = 0; + let mut count = 0; + while let Some((component, depth)) = stack.pop() { + for o in self.doc.occurrences.iter().filter(|o| o.parent == component) { + visited += 1; + if depth >= 64 || visited > 100_000 { + return Err(invalid("occurrence scope exceeds 64 levels or 100000 placements")); + } + if o.id == id { + count += 1; + } + stack.push((o.component, depth + 1)); + } + } + Ok(count) + } + + pub fn body_visible(&self, name: &str) -> bool { + if self.visibility.hidden_bodies.iter().any(|n| n == name) { + return false; + } + if self.visibility.hidden_occurrences.is_empty() { + return true; + } + if let Ok(mut cache) = self.occurrence_visibility_cache.lock() { + if cache.as_ref().is_none_or(|c| c.revision != self.revision || c.paths != self.visibility.hidden_occurrences) { + let bodies = self + .doc + .body_frames(&self.model.state()) + .into_iter() + .filter(|(_, _, path, _)| !self.occurrence_visible(path)) + .map(|(name, _, _, _)| name) + .collect(); + *cache = Some(VisibilityCache { revision: self.revision, paths: self.visibility.hidden_occurrences.clone(), bodies }); + } + return cache.as_ref().is_none_or(|c| !c.bodies.contains(name)); + } + self.doc.body_frames(&self.model.state()).iter().find(|(n, _, _, _)| n == name).is_none_or(|(_, _, path, _)| self.occurrence_visible(path)) + } + + pub(crate) fn prune_occurrence_state(&mut self) { + self.pending_moves.retain(|id, _| self.doc.occurrences.iter().any(|o| o.id == *id)); + if self.active_component != 0 && !self.doc.components.iter().any(|c| c.id == self.active_component) { + self.active_component = 0; + self.active_occurrence_path = None; + } + if self.active_occurrence_path.as_ref().is_some_and(|p| self.occurrence_placement(p).map_or(true, |p| p.component != self.active_component)) { + self.active_occurrence_path = None; + } + let selection = std::mem::take(&mut self.selection); + self.selection = selection.into_iter().filter(|s| !matches!(s, Sel::Occurrence {path} if self.occurrence_placement(path).is_err())).collect(); + let hidden = std::mem::take(&mut self.visibility.hidden_occurrences); + self.visibility.hidden_occurrences = hidden.into_iter().filter(|p| self.occurrence_placement(p).is_ok()).collect(); + } +} diff --git a/crates/engine/src/pattern_suppress_tests.rs b/crates/engine/src/pattern_suppress_tests.rs index f52dd25f..ee5ba758 100644 --- a/crates/engine/src/pattern_suppress_tests.rs +++ b/crates/engine/src/pattern_suppress_tests.rs @@ -33,3 +33,79 @@ fn suppressing_a_patterned_feature_removes_its_copies() { run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": false})); assert!((volume(&s, "Case") - full).abs() < 1e-6 * full); } + +#[test] +fn enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs() { + let mut s = Session::default(); + let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap(); + s.run_script(&script).unwrap(); + for (name, expression) in [("wall", "2.5 mm"), ("height", "30 mm"), ("board_d", "50 mm")] { + run(&mut s, "parameters.change", json!({"name":name,"expression":expression})); + } + assert!(s.model.results.iter().all(|result| result.error.is_none())); + for (name, z) in [("PCB standoff", 2.5), ("Lid boss", 2.5), ("Lid lip", 30.), ("Lid screw", 34.)] { + let feature = + s.doc.features.iter().find(|feature| feature.name == name && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap(); + assert!((s.model.state().sketch(feature.id).unwrap().plane.origin.z - z).abs() < 1e-6); + } + let usb = + s.doc.features.iter().find(|feature| feature.name == "USB" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap(); + assert!((s.model.state().sketch(usb.id).unwrap().plane.origin.y + 35.5).abs() < 1e-6); + let assert_material = |session: &Session| { + let state = session.world_state(); + let case = state.body("Case").unwrap().mesh(); + let lid = state.body("Lid").unwrap().mesh(); + assert!(case.contains(solvecraft_geom::Vec3::new(-24.5, -21.5, 7.))); + assert!(!case.contains(solvecraft_geom::Vec3::new(-26.5, -21.5, 7.))); + assert!(!case.contains(solvecraft_geom::Vec3::new(-10., -35.25, 11.35))); + assert!(case.contains(solvecraft_geom::Vec3::new(-10., -35.25, 15.))); + assert!( + lid.contains(solvecraft_geom::Vec3::new(0., 32.2, 29.)), + "lid bounds {:?}; lip {:?}; probes {:?}", + lid.bounds(), + session + .model + .state() + .sketch( + session + .doc + .features + .iter() + .find(|feature| feature.name == "Lid lip" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })) + .unwrap() + .id + ) + .unwrap() + .sketch + .links, + (20..35).map(|y| (y, lid.contains(solvecraft_geom::Vec3::new(0., f64::from(y) + 0.2, 29.)))).collect::>() + ); + assert!(!lid.contains(solvecraft_geom::Vec3::new(0., 32.9, 29.))); + assert!(!lid.contains(solvecraft_geom::Vec3::new(-33.5, -28.5, 32.))); + assert!(lid.contains(solvecraft_geom::Vec3::new(-31.5, -28.5, 32.))); + }; + assert_material(&s); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.results.iter().all(|result| result.error.is_none())); + assert_material(&reopened); +} + +#[test] +fn enclosure_inward_offset_collapse_reports_an_error_instead_of_a_stale_lip() { + let mut s = Session::default(); + let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap(); + s.run_script(&script).unwrap(); + run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"})); + // R4 outer corners leave R1 inner corners. The requested 1.4 mm inward + // offset needs a topology transition, which this offset operation refuses. + let lip = s.doc.features.iter().find(|f| f.name == "Lid lip" && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap(); + let result = s.model.result(lip.id).unwrap(); + assert!(result.error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")), "{result:?}"); + assert!(result.warning.as_deref().is_none_or(|warning| !warning.contains("identity is ambiguous")), "{result:?}"); + let state = s.model.state(); + assert!(state.sketch(lip.id).is_none()); + assert!(!state.body("Lid").unwrap().mesh().contains(solvecraft_geom::Vec3::new(0., 27.2, 24.))); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(lip.id).unwrap().error.as_deref().is_some_and(|error| error.contains("offset collapses an arc"))); + assert!(reopened.model.state().sketch(lip.id).is_none()); +} diff --git a/crates/engine/src/profile_refs_tests.rs b/crates/engine/src/profile_refs_tests.rs index 00d7e751..4354cbbf 100644 --- a/crates/engine/src/profile_refs_tests.rs +++ b/crates/engine/src/profile_refs_tests.rs @@ -30,7 +30,8 @@ fn enclosure() -> Session { {"command": "solid.extrude", "params": {"distance": "height", "body_name": "Enclosure"}}, {"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}}, {"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": "wall"}}, - {"command": "sketch.create", "params": {"plane": {"face": [40, 10, 20]}, "name": "USB port"}}, + {"command": "sketch.create", "params": {"plane": "YZ", "offset":"width / 2", "name": "USB port"}}, + {"command": "sketch.project", "params": {"refs":[{"face":[40,10,20],"body":"Enclosure"}]}}, {"command": "sketch.rectangle.center", "params": {"center": [0, 12], "corner": [6, 15.5]}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"distance": "-wall", "operation": "cut", "profiles": [1]}} @@ -58,6 +59,18 @@ fn a_port_picked_by_index_stays_a_cut_after_a_width_change() { assert!(after[0].1 > before[0].1 + 1000.0, "{before:?} {after:?}"); let id = s.doc.features.last().unwrap().id; assert!(s.model.result(id).is_some_and(|r| r.error.is_none()), "{:?}", s.model.result(id)); + let port = s.doc.find_feature("USB port").unwrap().id; + assert!((s.model.state().sketch(port).unwrap().plane.origin.x - 50.).abs() < 1e-6); + let world = s.world_state(); + let mesh = world.body("Enclosure").unwrap().mesh(); + assert!(!mesh.contains(solvecraft_geom::Vec3::new(49., 0., 12.))); + assert!(mesh.contains(solvecraft_geom::Vec3::new(49., 0., 20.))); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.result(id).unwrap().error.is_none()); + let world = reopened.world_state(); + let mesh = world.body("Enclosure").unwrap().mesh(); + assert!(!mesh.contains(solvecraft_geom::Vec3::new(49., 0., 12.))); + assert!(mesh.contains(solvecraft_geom::Vec3::new(49., 0., 20.))); } /// A pick the sketch no longer has is an error on the feature, never a different profile. diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index caa4f621..dc50b6b2 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -21,14 +21,14 @@ pub fn script() -> Value { {"command": "sketch.circle.center", "params": {"center": [68, 25], "diameter": 8}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"distance": "thickness", "operation": "cut", "name": "Holes"}}, - {"command": "sketch.create", "params": {"plane": {"face": [40, 25, 8]}, "name": "Boss"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": "thickness", "name": "Boss"}}, {"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 24}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"distance": 14, "operation": "join", "name": "Boss"}}, - {"command": "sketch.create", "params": {"plane": {"face": [40, 37, 22]}, "name": "Bore"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": "thickness + 14 mm", "name": "Bore"}}, {"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 12}}, {"command": "sketch.finish", "params": {}}, - {"command": "solid.extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}}, + {"command": "solid.extrude", "params": {"distance": "thickness + 14 mm", "direction": "negative", "operation": "cut", "name": "Bore"}}, ]}) } @@ -139,6 +139,44 @@ fn vents() -> Value { #[cfg(test)] mod tests { + #[test] + fn plate_boss_and_bore_planes_follow_thickness_without_weak_face_references() { + let mut session = crate::Session::default(); + session.run_script(&super::script()).unwrap(); + let boss = session + .doc + .features + .iter() + .find(|feature| feature.name == "Boss" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })) + .unwrap() + .id; + let bore = session + .doc + .features + .iter() + .find(|feature| feature.name == "Bore" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })) + .unwrap() + .id; + session.execute("parameters.change", &serde_json::json!({"name":"thickness","expression":"12 mm"})).unwrap(); + assert!(session.model.results.iter().all(|result| result.error.is_none())); + assert!((session.model.state().sketch(boss).unwrap().plane.origin.z - 12.).abs() < 1e-6); + assert!((session.model.state().sketch(bore).unwrap().plane.origin.z - 26.).abs() < 1e-6); + let world = session.world_state(); + let plate = world.body("Plate").unwrap().mesh(); + assert!((plate.bounds().max.z - 26.).abs() < 1e-6); + assert!(plate.contains(solvecraft_geom::Vec3::new(5., 25., 10.))); + assert!(plate.contains(solvecraft_geom::Vec3::new(40., 35., 24.))); + assert!(!plate.contains(solvecraft_geom::Vec3::new(40., 25., 24.))); + assert!(!plate.contains(solvecraft_geom::Vec3::new(40., 25., 2.))); + assert!(plate.contains(solvecraft_geom::Vec3::new(40., 35., 2.))); + let reopened = crate::Session::new(solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap()); + assert!((reopened.model.state().sketch(bore).unwrap().plane.origin.z - 26.).abs() < 1e-6); + let reopened_world = reopened.world_state(); + let reopened_plate = reopened_world.body("Plate").unwrap().mesh(); + assert!(!reopened_plate.contains(solvecraft_geom::Vec3::new(40., 25., 2.))); + assert!(reopened_plate.contains(solvecraft_geom::Vec3::new(40., 35., 2.))); + } + /// Every built-in sample builds without a failing feature or joint. #[test] fn samples_build_cleanly() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e156d671..b16559dc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -28,7 +28,19 @@ fn join_extrude_from_a_chamfered_top_face() { run(&mut s, "solid.extrude", json!({"distance": 20})); run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2})); let base = volume(&mut s); - run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}})); + let before = s.doc.to_json(); + let active = s.active_sketch; + assert!(s.execute("sketch.create", &json!({"plane":{"face":[0,0,20]}})).is_err(), "modified faces require an explicit fixed plane"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.active_sketch, active); + assert!(rel(volume(&mut s), base) < 1e-12); + let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20],"fixed":true}}))["sketch"].as_u64().unwrap(); + let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { plane }, sketch } = &s.doc.feature(id).unwrap().kind else { + panic!("explicit fixed face must become a custom plane"); + }; + assert!((plane.origin.z - 20.).abs() < 1e-9); + assert!(sketch.links.is_empty(), "fresh fixed plane does not create live auto-projections"); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":8})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"})); assert_eq!(s.model.state().bodies.len(), 1); @@ -482,7 +494,12 @@ fn step_open_then_model_on_it() { run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6})); want -= PI * 9.0 * 20.0; assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s)); - run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}})); + // Modified imported faces no longer have an immutable construction certificate. + // The public attachment refuses them; the user can explicitly choose a fixed plane. + let before_fixed = s.doc.to_json(); + assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err()); + assert_eq!(s.doc.to_json(), before_fixed); + run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}})); let c = [x0 + 10.0, y0 + 15.0]; // Sketch coordinates on the face are relative to the model origin projected onto it. run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]})); @@ -864,7 +881,14 @@ fn holes_at_sketch_points_threads_and_components() { let text = s.doc.to_json(); let back = solvecraft_doc::Document::from_json(&text).unwrap(); assert_eq!(back.components.len(), 2); - assert_eq!(back.body_components.len(), 1); + let assigned = r["components"][0]["component"].as_u64().unwrap(); + let transfer = r["components"][0]["feature"].as_u64().unwrap(); + assert!( + matches!(&back.feature(transfer).unwrap().kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Shaft"] && *to == assigned) + ); + let reopened = Session::new(back); + assert_eq!(reopened.model.state().body("Shaft").unwrap().component, assigned); + assert_eq!(reopened.world_state().threads.len(), s.world_state().threads.len()); } #[test] @@ -985,11 +1009,22 @@ fn components_occurrences_and_world_placement() { run(&mut s, "component.capture_position", json!({})); assert!(s.pending_moves.is_empty()); assert!((bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap() - 5.0).abs() < 1e-9); - // Bodies and sketches move between components. - run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": "Arm"})); + // A definition placed twice cannot receive one world body without duplicating it. + let document = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let error = s.execute("component.move_bodies", &json!({"bodies": ["Base"], "component": "Arm"})).unwrap_err(); + assert!(error.to_string().contains("one source and destination placement"), "{error}"); + assert_eq!(s.doc.to_json(), document); + assert_eq!(s.pending_moves, pending); + // The independent Paste New result has one placement and can receive it. + let copied = pn["component"].as_u64().unwrap(); + run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": copied})); let l = run(&mut s, "component.list", json!({})); let arm = l["components"].as_array().unwrap().iter().find(|x| x["id"] == cid).unwrap().clone(); - assert!(arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}"); + assert!(!arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}"); + let receiver = l["components"].as_array().unwrap().iter().find(|x| x["id"] == copied).unwrap(); + assert!(receiver["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{receiver}"); + assert_eq!(bbox(&mut s, "Base")["min"], json!([0.0, 0.0, 0.0])); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "component.move_sketches", json!({"sketches": ["S1"], "component": cid})); @@ -999,8 +1034,9 @@ fn components_occurrences_and_world_placement() { assert_eq!(s.doc.find_feature("S1").unwrap().component, 0); run(&mut s, "edit.redo", json!({})); run(&mut s, "timeline.roll_to", json!({"position": 1})); - // Only Base is left; it now lives in Arm, which is placed twice (original and instance). - assert_eq!(s.world_state().bodies.len(), 2); + // The ownership boundary is later: before it Base remains the sole root body. + assert_eq!(s.world_state().bodies.len(), 1); + assert_eq!(s.world_state().body("Base").unwrap().component, 0); run(&mut s, "timeline.roll_to", json!({})); // Saved and loaded; older designs get occurrences. let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); @@ -1568,20 +1604,71 @@ mod naming { run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": 5, "operation": "join", "name": "Peg"})); let top = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0); assert!((top(&mut s) - 15.0).abs() < 1e-6); - // Upstream, a tall boss over the picked point: the nearest upward face is now its top, - // but the sketch keeps to the block's top face by name. - run(&mut s, "timeline.roll_to", json!({"feature": "Block"})); - run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"})); - run(&mut s, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]})); - run(&mut s, "sketch.finish", json!({})); - run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"})); - run(&mut s, "timeline.roll_to", json!({})); - // (Its projected face outline changed, which it says; its plane did not.) - assert!(!warning(&s, "Top").unwrap_or_default().contains("no longer exists")); - // The peg starts on the block's top (z = 10), inside the boss: the part stays 20 tall. + let attached = |s: &Session| s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).map(|sketch| sketch.plane.origin.z); + assert_eq!(attached(&s), Some(10.)); + run(&mut s, "timeline.edit", json!({"feature":"Block","set":{"extent":{"distance":"12"}}})); + assert_eq!(attached(&s), Some(12.)); + assert!((top(&mut s) - 17.).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + assert_eq!(attached(&s), Some(10.)); + assert!((top(&mut s) - 15.).abs() < 1e-6); + let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let mut model = solvecraft_doc::Model::new(); + model.evaluate(&reopened); + assert_eq!(model.state().sketch(reopened.find_feature("Top").unwrap().id).unwrap().plane.origin.z, 10.); + assert!((model.state().bodies[0].mesh().bounds().max.z - 15.).abs() < 1e-6); + // A historical format-10 attachment uses its saved generic face name and no strong anchor. + let mut old = (*s.doc).clone(); + let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor, name, .. }, sketch } = + &mut old.features.iter_mut().find(|f| f.name == "Top").unwrap().kind + else { + panic!("fixture must contain a face sketch"); + }; + *anchor = None; + for link in &mut sketch.links { + if link.auto_face { + link.auto_face = false; + link.face_name = name.clone(); + } + } + old.format = "solvecraft/10".into(); + let mut legacy = Session::new(solvecraft_doc::Document::from_json(&old.to_json()).unwrap()); + assert_eq!(attached(&legacy), Some(10.)); + assert!((top(&mut legacy) - 15.).abs() < 1e-6); + // The upstream join consumes the certified body; new attachments cannot follow its substitute roof. + for target in [&mut s, &mut legacy] { + run(target, "timeline.roll_to", json!({"feature": "Block"})); + run(target, "sketch.create", json!({"plane": "XY", "name": "B"})); + run(target, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]})); + run(target, "sketch.finish", json!({})); + run(target, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"})); + run(target, "timeline.roll_to", json!({})); + } + assert_eq!(attached(&legacy), Some(10.), "historical weak naming retains its saved geometry behavior"); + assert!((top(&mut legacy) - 20.).abs() < 1e-6); + let legacy_reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap()); + assert_eq!(attached(&legacy_reopened), Some(10.)); + assert!((legacy_reopened.model.state().bodies[0].mesh().bounds().max.z - 20.).abs() < 1e-6); + let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor, .. }, .. } = + &legacy_reopened.doc.find_feature("Top").unwrap().kind + else { + panic!("saved historical face reference must remain present"); + }; + assert!(anchor.is_none(), "opening old face references must not infer a strict attachment"); + let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some(); + assert!(failed(&s, "Top")); + assert!(failed(&s, "Peg"), "lost sketch cannot supply downstream geometry"); + assert_eq!(attached(&s), None); assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s)); - let sk = s.model.state().sketch(s.doc.find_feature("Top").map(|f| f.id).unwrap_or(0)).map(|x| x.plane.origin.z); - assert_eq!(sk.map(|z| (z * 1e6).round() / 1e6), Some(10.0)); + run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true})); + assert!(!failed(&s, "Top")); + assert!(!failed(&s, "Peg")); + assert_eq!(attached(&s), Some(10.)); + assert!((top(&mut s) - 15.).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + assert!(failed(&s, "Top")); + assert!(failed(&s, "Peg")); + assert_eq!(attached(&s), None); } #[test] diff --git a/crates/engine/src/tests/component_frames.rs b/crates/engine/src/tests/component_frames.rs index 081e5a95..b6ce2d53 100644 --- a/crates/engine/src/tests/component_frames.rs +++ b/crates/engine/src/tests/component_frames.rs @@ -24,8 +24,9 @@ fn moved_component_picks_and_frames() { // Root active: a fillet picked at the moved edge in the world. run(&mut s, "solid.fillet", json!({"edges": [[25, 54, 4]], "radius": 1})); assert!(rel(vol(&mut s, "ArmBody"), 160.0 - (1.0 - PI / 4.0) * 10.0) < 1e-4, "{}", vol(&mut s, "ArmBody")); - // A sketch on the moved body's top face: its points sit on that face in the world. - let sk = run(&mut s, "sketch.create", json!({"plane": {"face": [25, 52, 4]}})); + // An explicit fixed plane on the modified body's top keeps the world pick; + // unsupported modifier lineage cannot silently create a weak attachment. + let sk = run(&mut s, "sketch.create", json!({"plane": {"face": [25, 52, 4],"fixed":true}})); run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 1})); run(&mut s, "sketch.finish", json!({})); let si = run(&mut s, "sketch.inspect", json!({"sketch": sk["sketch"]})); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 52b3a675..05eab29d 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -42,7 +42,7 @@ pub fn bounds(s: &Session) -> Aabb3 { let st = s.world_state(); let mut b = Aabb3::EMPTY; let v = &s.visibility; - for body in st.bodies.iter().filter(|x| !v.hidden_bodies.contains(&x.name)) { + for body in st.bodies.iter().filter(|x| s.body_visible(&x.name)) { b = b.union(&body.mesh().bounds()); } for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) { @@ -152,8 +152,17 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false }); } - for body in st.bodies.iter().filter(|b| !s.visibility.hidden_bodies.contains(&b.name)) { - let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name)); + let selected_occurrences: Vec = s + .selection + .iter() + .filter_map(|x| match x { + Sel::Occurrence { path } => s.occurrence_bodies(path, true).ok(), + _ => None, + }) + .flatten() + .collect(); + for body in st.bodies.iter().filter(|b| s.body_visible(&b.name)) { + let selected = selected_occurrences.contains(&body.name) || s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name)); let mesh = body.mesh(); // The body's appearance (or imported colour) and faces with looks of their own, as in // the viewport. diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 41278c21..44998c36 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -5,6 +5,9 @@ use truck_modeling as mt; use crate::{KernelError, Result, guard}; +#[path = "shell_groups.rs"] +mod shell_groups; + pub(crate) type Solid = mt::Solid; /// A solid body (one or more closed shells), or a mesh body (triangles only, like an imported @@ -24,6 +27,31 @@ pub struct Body { /// Lines the user split faces along (Split Face), as polylines: export keeps the faces on /// either side apart where it would otherwise join pieces of one surface. pub(crate) splits: Option>>>, + /// Exact construction/import source identities, in this body's face order. + pub(crate) face_sources: Option>>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum FaceSource { + ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option }, + CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option }, + ExtrudeCap { high: bool, region: solvecraft_geom::Region2, plane: solvecraft_geom::Plane }, + CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec, holes: Vec>, plane: solvecraft_geom::Plane }, + Box { producer: Option, axis: u8, high: bool, plane: solvecraft_geom::Plane }, + Imported { item: u64, face: u64, payload_sha256: Option, producer: Option }, +} + +pub type CapSignature = (Vec, Vec>); +type FaceSources = std::sync::Arc>>; + +impl FaceSource { + fn plane_mut(&mut self) -> Option<&mut solvecraft_geom::Plane> { + match self { + Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(), + Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } => Some(plane), + Self::Imported { .. } => None, + } + } } /// How a body looks beyond its colour, for export: its opacity and its faces' own colours. @@ -284,6 +312,7 @@ impl Body { && self.color == other.color && self.paint == other.paint && self.splits == other.splits + && self.face_sources == other.face_sources } pub(crate) fn new(solid: Solid) -> Result { @@ -321,9 +350,186 @@ impl Body { color: None, paint: None, splits: None, + face_sources: None, }) } + pub fn face_sources(&self) -> Option<&[Option]> { + self.face_sources.as_deref().map(Vec::as_slice) + } + + /// An exact construction/import plane; never inferred from tessellation. + pub fn face_plane(&self, index: usize) -> Option { + if index >= 50_000 { + return None; + } + let source = self.face_sources()?.get(index)?.as_ref()?; + match source { + FaceSource::ExtrudeSide { plane, .. } | FaceSource::CertifiedExtrudeSide { plane, .. } => *plane, + FaceSource::ExtrudeCap { plane, .. } | FaceSource::CertifiedExtrudeCap { plane, .. } | FaceSource::Box { plane, .. } => Some(*plane), + FaceSource::Imported { .. } => self.planar_face(index), + } + } + + /// Exact current planar surface for an explicitly fixed snapshot, without source lineage. + pub fn planar_face(&self, index: usize) -> Option { + if index >= 50_000 { + return None; + } + guard("exact planar face", || { + let plane = self.solid.face_iter().nth(index).and_then(|face| match face.oriented_surface() { + mt::Surface::Plane(plane) => { + let u = plane.u_axis(); + let v = plane.v_axis(); + solvecraft_geom::Plane::new(from_p3(plane.origin()), Vec3::new(u.x, u.y, u.z), Vec3::new(v.x, v.y, v.z)) + } + _ => None, + }); + Ok(plane) + }) + .ok() + .flatten() + } + + pub fn with_box_certificate(mut self, producer: u64) -> Result { + if producer == 0 { + return Err(KernelError::Invalid("invalid box producer".into())); + } + if let Some(sources) = &self.face_sources { + let mut sources = sources.as_ref().clone(); + for source in sources.iter_mut().flatten() { + if let FaceSource::Box { producer: id, .. } = source { + *id = Some(producer); + } + } + self.face_sources = Some(std::sync::Arc::new(sources)); + } + Ok(self) + } + + /// Cap signatures are in face order; unknown or ambiguous source curves stay uncertified. + pub fn with_extrude_cap_certificates(mut self, producer: u64, sketch: u64, signatures: &[Option]) -> Result { + if producer == 0 || sketch == 0 { + return Err(KernelError::Invalid("invalid cap producer".into())); + } + if let Some(sources) = &self.face_sources { + if sources.len() != signatures.len() { + return Err(KernelError::Invalid("cap certificate count mismatch".into())); + } + let mut sources = sources.as_ref().clone(); + for (source, signature) in sources.iter_mut().zip(signatures) { + if let Some(FaceSource::ExtrudeCap { high, plane, .. }) = source { + let Some((outer, holes)) = signature else { + *source = None; + continue; + }; + if outer.len() > 4096 || holes.len() > 4096 || holes.iter().any(|hole| hole.len() > 4096) { + return Err(KernelError::Invalid("oversized cap curve signature".into())); + } + let count = outer.len() + holes.iter().map(Vec::len).sum::(); + if outer.is_empty() + || count > 4096 + || holes.len() > 4096 + || outer.iter().chain(holes.iter().flatten()).map(String::len).sum::() > 65_536 + || outer.iter().chain(holes.iter().flatten()).any(|id| id.is_empty() || id.len() > 128) + { + return Err(KernelError::Invalid("invalid cap curve signature".into())); + } + let mut outer = outer.clone(); + outer.sort(); + outer.dedup(); + let mut holes = holes.clone(); + for hole in &mut holes { + if hole.is_empty() { + return Err(KernelError::Invalid("empty cap hole signature".into())); + } + hole.sort(); + hole.dedup(); + } + holes.sort(); + if holes.windows(2).any(|pair| pair[0] == pair[1]) || holes.iter().flatten().any(|id| outer.binary_search(id).is_ok()) { + return Err(KernelError::Invalid("ambiguous cap hole signature".into())); + } + *source = Some(FaceSource::CertifiedExtrudeCap { producer, sketch, high: *high, outer, holes, plane: *plane }); + } + } + self.face_sources = Some(std::sync::Arc::new(sources)); + } + Ok(self) + } + + pub(crate) fn transformed_sources(&self, m: [[f64; 4]; 4]) -> Result> { + let Some(sources) = &self.face_sources else { return Ok(None) }; + let vector = |p: Vec3| { + Vec3::new( + m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z, + m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z, + m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z, + ) + }; + let translation = Vec3::new(m[3][0], m[3][1], m[3][2]); + let reflected = vector(Vec3::X).dot(vector(Vec3::Y).cross(vector(Vec3::Z))) < 0.; + let mut sources = sources.as_ref().clone(); + for source in sources.iter_mut().flatten() { + if let Some(plane) = source.plane_mut() { + *plane = solvecraft_geom::Plane::new( + vector(plane.origin) + translation, + vector(plane.x), + vector(plane.y) * if reflected { -1. } else { 1. }, + ) + .ok_or_else(|| KernelError::Invalid("degenerate transformed face plane".into()))?; + } + } + Ok(Some(std::sync::Arc::new(sources))) + } + + /// Bind constructed edges to uniquely proven source curves without changing face names. + pub fn with_extrude_certificates(mut self, producer: u64, sketch: u64, curves: &[Option]) -> Result { + if producer == 0 || sketch == 0 || curves.iter().flatten().any(|curve| curve.is_empty() || curve.len() > 128) { + return Err(KernelError::Invalid("invalid constructed side certificate".into())); + } + if let Some(sources) = &self.face_sources { + if sources.len() != curves.len() { + return Err(KernelError::Invalid("side certificate count mismatch".into())); + } + let sources = sources + .iter() + .zip(curves) + .map(|(source, curve)| match (source, curve) { + (Some(FaceSource::ExtrudeSide { plane, .. }), Some(curve)) => { + Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone(), plane: *plane }) + } + (Some(FaceSource::ExtrudeSide { .. }), None) => None, + _ => source.clone(), + }) + .collect(); + self.face_sources = Some(std::sync::Arc::new(sources)); + } + Ok(self) + } + + pub fn with_import_certificate(mut self, digest: &str, producer: u64) -> Result { + if producer == 0 || digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { + return Err(KernelError::Invalid("invalid immutable import certificate".into())); + } + if let Some(sources) = &self.face_sources { + let mut sources = sources.as_ref().clone(); + for source in sources.iter_mut().flatten() { + if let FaceSource::Imported { payload_sha256, producer: origin, .. } = source { + *payload_sha256 = Some(digest.into()); + *origin = Some(producer); + } + } + self.face_sources = Some(std::sync::Arc::new(sources)); + } + Ok(self) + } + + pub(crate) fn with_face_sources(mut self, sources: Option>>>) -> Self { + self.face_sources = sources; + self + } + /// A deep copy of the solid that can be mutated without affecting other bodies. pub(crate) fn deep_copy(&self) -> Solid { mt::builder::clone(&*self.solid) @@ -674,51 +880,97 @@ impl Body { /// The separate pieces of a body: each outer shell with the voids inside it. A body in one /// piece comes back as itself. pub fn lumps(&self) -> Result> { + self.lumps_with_clearance(0.0) + } + + /// A boolean shortcut needs clearance larger than both shell mesh deflections. + pub(crate) fn separated_lumps(&self) -> Result> { + self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3)) + } + + fn lumps_with_clearance(&self, clearance: f64) -> Result> { if self.mesh.is_some() || self.solid.boundaries().len() < 2 { return Ok(vec![self.clone()]); } let tol = (self.size() * 1e-3).max(1e-3); guard("lumps", || { let shells: Vec = self.solid.boundaries().clone(); - let meshes: Vec<(f64, Option)> = shells + if shells.len() > shell_groups::MAX_SHELLS + || shells.iter().flat_map(mt::Shell::vertex_iter).take(shell_groups::MAX_POINTS + 1).count() > shell_groups::MAX_POINTS + || shells.iter().flat_map(mt::Shell::face_iter).take(shell_groups::MAX_TRIANGLES + 1).count() > shell_groups::MAX_TRIANGLES + { + return Err(KernelError::Failed("lumps: topology exceeds the shell grouping limit".into())); + } + let (mut point_count, mut triangle_count) = (0usize, 0usize); + let data: Vec<(f64, Mesh)> = shells .iter() .map(|sh| { - let pm = sh.robust_triangulation(tol).to_polygon(); - let v = pm.volume(); + let meshed = sh.robust_triangulation(tol); + if meshed.face_iter().any(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())) { + return Err(KernelError::Failed("lumps: cannot mesh a complete shell boundary".into())); + } + let pm = meshed.to_polygon(); + point_count = point_count + .checked_add(pm.positions().len()) + .ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?; + triangle_count = triangle_count + .checked_add(pm.faces().triangle_iter().take(shell_groups::MAX_TRIANGLES + 1).count()) + .ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?; + if point_count > shell_groups::MAX_POINTS || triangle_count > shell_groups::MAX_TRIANGLES { + return Err(KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into())); + } let mut m = Mesh::default(); for p in pm.positions() { m.positions.push(Vec3::new(p.x, p.y, p.z)); } for t in pm.faces().triangle_iter() { - let idx = |k: usize| t.get(k).map(|x| u32::try_from(x.pos).unwrap_or(0)).unwrap_or(0); - m.triangles.push([idx(0), idx(1), idx(2)]); + let idx = |k: usize| { + t.get(k).and_then(|x| u32::try_from(x.pos).ok()).ok_or_else(|| KernelError::Failed("lumps: invalid mesh index".into())) + }; + let indices = [idx(0)?, idx(1)?, idx(2)?]; + let [a, b, c] = m.tri(&indices).ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))?; + // STEP sphere poles can produce zero-area duplicate triangles. + // They have no boundary area or volume and are not ray crossings. + if (b - a).cross(c - a) == Vec3::ZERO { + continue; + } + m.triangles.push(indices); m.tri_face.push(0); } - (v, Some(m)) + let origin = m.positions.first().copied().ok_or_else(|| KernelError::Failed("lumps: empty mesh".into()))?; + let v = m + .triangles + .iter() + .map(|t| { + m.tri(t) + .map(|[a, b, c]| (a - origin).dot((b - origin).cross(c - origin)) / 6.0) + .ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into())) + }) + .collect::>>()? + .into_iter() + .sum(); + Ok((v, m)) }) - .collect(); - let outer: Vec = (0..shells.len()).filter(|i| meshes.get(*i).is_some_and(|m| m.0 > 0.0)).collect(); - if outer.len() < 2 { + .collect::>()?; + let (volumes, meshes): (Vec<_>, Vec<_>) = data.into_iter().unzip(); + let groups = shell_groups::groups(&meshes, &volumes, (self.size() * 1e-8).max(1e-9).max(clearance))?; + if groups.len() == 1 { return Ok(vec![self.clone()]); } - let mut groups: Vec> = outer.iter().filter_map(|i| shells.get(*i).cloned()).map(|s| vec![s]).collect(); - for (i, sh) in shells.iter().enumerate() { - if outer.contains(&i) { - continue; - } - // A void goes with the outer shell around one of its points. - let probe = sh.vertex_iter().next().map(|v| from_p3(v.point())); - let owner = outer.iter().position(|o| { - let (Some(p), Some((_, Some(m)))) = (probe, meshes.get(*o)) else { return false }; - m.contains(p) - }); - if let Some(k) = owner - && let Some(g) = groups.get_mut(k) - { - g.push(sh.clone()); - } - } - groups.into_iter().map(|g| Solid::try_new(g).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)).collect() + groups + .into_iter() + .map(|g| { + let shells = g + .into_iter() + .map(|i| shells.get(i).cloned().ok_or_else(|| KernelError::Failed("lumps: missing shell".into()))) + .collect::>>()?; + Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new) + }) + .collect() }) } } + +#[cfg(test)] +#[path = "body_lump_tests.rs"] +mod lump_tests; diff --git a/crates/kernel/src/body_lump_tests.rs b/crates/kernel/src/body_lump_tests.rs new file mode 100644 index 00000000..722ef31a --- /dev/null +++ b/crates/kernel/src/body_lump_tests.rs @@ -0,0 +1,224 @@ +use super::*; +use crate::{BoolOp, boolean, box_solid, measure, sphere, step_export, step_import}; + +fn cube(half: f64) -> Body { + box_solid(Vec3::new(-half, -half, -half), Vec3::new(half, half, half)).unwrap() +} + +fn nested_island() -> Body { + let hollow = boolean(&cube(50.0), &cube(40.0), BoolOp::Cut).unwrap().unwrap(); + // This order puts the containing outer shell ahead of the disconnected island. + let joined = boolean(&cube(20.0), &hollow, BoolOp::Union).unwrap().unwrap(); + boolean(&joined, &cube(10.0), BoolOp::Cut).unwrap().unwrap() +} + +fn assert_material(body: &Body, expected: f64, probes: &[(Vec3, bool)]) { + assert!(body.validity().is_empty(), "{:?}", body.validity()); + let mesh = body.tessellate_with(0.01, true).unwrap(); + assert!((mesh.measure().volume - expected).abs() < expected * 1e-6, "{} vs {expected}", mesh.measure().volume); + for (p, want) in probes { + assert_eq!(mesh.contains(*p), *want, "material at {p:?}"); + } +} + +fn check_nested(body: &Body) { + let probes = [(Vec3::ZERO, false), (Vec3::X * 15.0, true), (Vec3::X * 30.0, false), (Vec3::X * 45.0, true)]; + assert_material(body, 544_000.0, &probes); + let lumps = body.lumps().unwrap(); + assert_eq!(lumps.len(), 2); + let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect(); + volumes.sort_by(f64::total_cmp); + for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) { + assert!((got - want).abs() < 1e-6, "{volumes:?}"); + } + let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect(); + for (p, want) in probes { + assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), usize::from(want), "lump material at {p:?}"); + } +} + +#[test] +fn nested_cavities_stay_with_their_material_islands_for_every_shell_order() { + let body = nested_island(); + check_nested(&body); + let before = step_export(&[&body], "nested cavities").unwrap(); + let shells = body.deep_copy().boundaries().clone(); + assert_eq!(shells.len(), 4); + for a in 0..4 { + for b in 0..4 { + for c in 0..4 { + for d in 0..4 { + let order = [a, b, c, d]; + if order.iter().enumerate().any(|(i, x)| order.iter().skip(i + 1).any(|y| x == y)) { + continue; + } + let permuted = guard("permuted cavity shells", || { + Body::new(Solid::try_new(order.into_iter().map(|i| shells[i].clone()).collect()).unwrap()) + }) + .unwrap(); + check_nested(&permuted); + } + } + } + } + assert_eq!(step_export(&[&body], "nested cavities").unwrap(), before); +} + +#[test] +fn nested_voids_survive_step_round_trip_and_further_cuts() { + let body = nested_island(); + let text = step_export(&[&body], "nested cavities").unwrap(); + let imported = step_import(&text).unwrap(); + assert!(imported.warnings.is_empty(), "{:?}", imported.warnings); + let mut lumps = Vec::new(); + for imported in imported.bodies { + lumps.extend(imported.body.lumps().unwrap()); + } + assert_eq!(lumps.len(), 2); + let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect(); + volumes.sort_by(f64::total_cmp); + for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) { + assert!((got - want).abs() < 1e-3, "{volumes:?}"); + } + let opening = box_solid(Vec3::new(5.0, -2.0, -2.0), Vec3::new(25.0, 2.0, 2.0)).unwrap(); + let mut cut_volume = 0.0; + for lump in &lumps { + if let Some(cut) = boolean(lump, &opening, BoolOp::Cut).unwrap() { + cut_volume += measure(&cut).unwrap().volume; + assert!(!cut.tessellate_with(0.01, true).unwrap().contains(Vec3::X * 15.0)); + } + } + // Only x=10..20 is material along this 4×4 opening: 160 mm³ removed. + assert!((cut_volume - 543_840.0).abs() < 1e-3, "{cut_volume}"); +} + +#[test] +fn curved_internal_void_and_disconnected_material_keep_analytic_volume() { + let hollow = boolean(&cube(20.0), &sphere(Vec3::ZERO, 10.0).unwrap(), BoolOp::Cut).unwrap().unwrap(); + let island = sphere(Vec3::ZERO, 4.0).unwrap(); + let joined = boolean(&island, &hollow, BoolOp::Union).unwrap().unwrap(); + let reverse = boolean(&hollow, &island, BoolOp::Union).unwrap().unwrap(); + assert!((measure(&joined).unwrap().volume - measure(&reverse).unwrap().volume).abs() < 1e-4); + let body = boolean(&joined, &sphere(Vec3::ZERO, 2.0).unwrap(), BoolOp::Cut).unwrap().unwrap(); + let lumps = body.lumps().unwrap(); + assert_eq!(lumps.len(), 2); + let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect(); + volumes.sort_by(f64::total_cmp); + let pi = std::f64::consts::PI; + let want = [4.0 / 3.0 * pi * (64.0 - 8.0), 64_000.0 - 4.0 / 3.0 * pi * 1000.0]; + for (got, want) in volumes.iter().zip(want) { + assert!((got - want).abs() < want * 0.002, "{got} vs {want}"); + } + let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect(); + for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] { + assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), want); + } + let imported = step_import(&step_export(&[&body], "curved nested cavities").unwrap()).unwrap(); + assert!(imported.warnings.is_empty(), "{:?}", imported.warnings); + let imported: Vec<_> = imported.bodies.into_iter().flat_map(|b| b.body.lumps().unwrap()).collect(); + assert_eq!(imported.len(), 2); + let imported_meshes: Vec<_> = imported.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect(); + for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] { + assert_eq!(imported_meshes.iter().filter(|m| m.contains(p)).count(), want); + } +} + +fn joined_shells(shells: Vec) -> Body { + guard("invalid ownership fixture", || Body::new(Solid::try_new(shells).unwrap())).unwrap() +} + +#[test] +fn unowned_overlapping_and_touching_cavities_are_errors_not_dropped_geometry() { + let outer = cube(50.0); + for (min, max) in [ + (Vec3::new(60.0, 60.0, 60.0), Vec3::new(70.0, 70.0, 70.0)), + (Vec3::new(45.0, -5.0, -5.0), Vec3::new(55.0, 5.0, 5.0)), + (Vec3::new(40.0, -5.0, -5.0), Vec3::new(50.0, 5.0, 5.0)), + ] { + let mut inner = box_solid(min, max).unwrap().deep_copy(); + inner.not(); + let body = joined_shells(outer.deep_copy().boundaries().iter().chain(inner.boundaries()).cloned().collect()); + let before = step_export(&[&body], "invalid ownership").unwrap(); + assert!(body.lumps().is_err()); + assert_eq!(step_export(&[&body], "invalid ownership").unwrap(), before); + } + let mut a = box_solid(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0)).unwrap().deep_copy(); + a.not(); + let mut b = box_solid(Vec3::new(0.0, -10.0, -10.0), Vec3::new(20.0, 10.0, 10.0)).unwrap().deep_copy(); + b.not(); + let body = joined_shells(outer.deep_copy().boundaries().iter().chain(a.boundaries()).chain(b.boundaries()).cloned().collect()); + assert!(body.lumps().is_err()); +} + +#[test] +fn shell_grouping_preserves_large_disconnected_patterns_and_bounds_hostile_work() { + let mesh = cube(1.0).tessellate_with(0.01, true).unwrap(); + let copies: Vec<_> = (0..512) + .map(|i| { + let mut m = mesh.clone(); + for p in &mut m.positions { + p.x += f64::from(i) * 4.0; + } + m + }) + .collect(); + assert_eq!(shell_groups::groups(&copies, &vec![8.0; 512], 1e-8).unwrap().len(), 512); + let excessive = vec![Mesh::default(); shell_groups::MAX_SHELLS + 1]; + assert!(shell_groups::groups(&excessive, &vec![1.0; excessive.len()], 1e-8).unwrap_err().to_string().contains("shell count")); + let mut oversized = mesh.clone(); + oversized.positions.resize(shell_groups::MAX_POINTS + 1, Vec3::ZERO); + assert!(shell_groups::groups(&[oversized], &[8.0], 1e-8).unwrap_err().to_string().contains("grouping limit")); + let mut repeated = mesh.clone(); + repeated.triangles = mesh.triangles.iter().copied().cycle().take(7000).collect(); + assert!(shell_groups::groups(&[repeated.clone(), repeated], &[8.0, 8.0], 1e-8).unwrap_err().to_string().contains("boundary work")); + let mut outer = cube(2.0).tessellate_with(0.01, true).unwrap(); + let mut inner = mesh; + outer.triangles = outer.triangles.iter().copied().cycle().take(4000).collect(); + inner.triangles = inner.triangles.iter().copied().cycle().take(4000).collect(); + inner.positions.resize(4000, Vec3::ZERO); + assert!(shell_groups::groups(&[outer, inner], &[64.0, -8.0], 1e-8).unwrap_err().to_string().contains("containment work")); +} + +#[test] +fn translated_and_scaled_nested_voids_keep_material_membership() { + let body = nested_island(); + for scale in [0.01, 0.25, 2.0] { + let origin = Vec3::new(1234.0, -987.0, 765.0); + let transformed = crate::transform_matrix( + &body, + [[scale, 0.0, 0.0, 0.0], [0.0, scale, 0.0, 0.0], [0.0, 0.0, scale, 0.0], [origin.x, origin.y, origin.z, 1.0]], + ) + .unwrap(); + let lumps = transformed + .lumps() + .unwrap_or_else(|e| panic!("scale={scale}: {e}; bounds={:?}", transformed.tessellate_with(1e-4, true).unwrap().bounds())); + assert_eq!(lumps.len(), 2); + let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with((scale * 0.01).max(1e-5), true).unwrap()).collect(); + for (x, want) in [(0.0, 0), (15.0, 1), (30.0, 0), (45.0, 1)] { + assert_eq!(meshes.iter().filter(|m| m.contains(origin + Vec3::X * (x * scale))).count(), want); + } + } +} + +#[test] +fn shortcut_clearance_refuses_curved_contact_hidden_between_mesh_vertices() { + let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap(); + let a = sphere(Vec3::ZERO, 10.0).unwrap(); + for distance in [19.999, 20.001] { + let b = sphere(direction * distance, 10.0).unwrap(); + for shells in [ + a.deep_copy().boundaries().iter().chain(b.deep_copy().boundaries()).cloned().collect(), + b.deep_copy().boundaries().iter().chain(a.deep_copy().boundaries()).cloned().collect(), + ] { + assert!(joined_shells(shells).separated_lumps().is_err(), "distance {distance}"); + } + } + let far = sphere(direction * 21.0, 10.0).unwrap(); + let safe = crate::ops::join_pieces(vec![a, far]).unwrap().unwrap(); + assert_eq!(safe.separated_lumps().unwrap().len(), 2); + let outer = sphere(Vec3::ZERO, 10.0).unwrap(); + let mut protruding = sphere(direction * 9.001, 1.0).unwrap().deep_copy(); + protruding.not(); + let invalid = joined_shells(outer.deep_copy().boundaries().iter().chain(protruding.boundaries()).cloned().collect()); + assert!(invalid.separated_lumps().is_err()); +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index f6fc3ba6..cda62fb7 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -15,6 +15,10 @@ fn finite(x: f64, what: &str) -> Result { /// A closed wire in 3D from a sketch loop. pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result { + wire_sources(plane, lp).map(|(wire, _)| wire) +} + +fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2)>)> { let n = lp.segs.len(); if n == 0 || n > MAX_SEGS { return Err(KernelError::Invalid(format!("loop with {n} segments"))); @@ -24,6 +28,7 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result { } let verts: Vec = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect(); let mut edges = Vec::with_capacity(n); + let mut sources = Vec::with_capacity(n); for (i, s) in lp.segs.iter().enumerate() { let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue }; let e: mt::Edge = match *s { @@ -35,7 +40,9 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result { // Split big arcs so every piece is well conditioned. if sweep.abs() > std::f64::consts::PI * 1.5 { let vm = builder::vertex(p3(plane.to_world(s.mid()))); - edges.push(builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))))); + let first = builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25)))); + sources.push((first.clone(), *s)); + edges.push(first); builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75)))) } else { builder::circle_arc(a, b, p3(plane.to_world(s.mid()))) @@ -45,9 +52,10 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result { crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))? } }; + sources.push((e.clone(), *s)); edges.push(e); } - Ok(edges.into()) + Ok((edges.into(), sources)) } /// A planar face from a region (outer loop with holes). @@ -82,6 +90,15 @@ fn region_ok(r: &Region2) -> Result<()> { /// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`). pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result> { + if !plane.origin.is_finite() + || !plane.x.is_finite() + || !plane.y.is_finite() + || (plane.x.len2() - 1.).abs() > 1e-8 + || (plane.y.len2() - 1.).abs() > 1e-8 + || plane.x.dot(plane.y).abs() > 1e-8 + { + return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into())); + } let (start, end) = (finite(start, "start")?, finite(end, "end")?); let (lo, hi) = if end >= start { (start, end) } else { (end, start) }; if hi - lo < 1e-6 { @@ -95,15 +112,105 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu for r in regions { region_ok(r)?; let base = plane.offset(lo); - let f = face(&base, r)?; + let (outer, mut sources) = wire_sources(&base, &r.outer.ccw())?; + let mut wires = vec![outer]; + for hole in &r.holes { + let (wire, mut hole_sources) = wire_sources(&base, &hole.ccw().reversed())?; + wires.push(wire); + sources.append(&mut hole_sources); + } + let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?; let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?; - out.push(Body::new(solid)?); + let upper = guard("extrude cap identities", || { + let inputs: std::collections::HashSet<_> = sources.iter().map(|(edge, _)| edge.id()).collect(); + let mut visits = 0usize; + let mut candidate = None; + for (index, generated) in solid.face_iter().enumerate() { + if index >= 50_000 { + return Ok(None); + } + let mut side = false; + for edge in generated.absolute_boundaries().iter().flatten() { + visits += 1; + if visits > 200_000 { + return Ok(None); + } + side |= inputs.contains(&edge.id()); + } + if generated.id() != f.id() && !side { + if candidate.is_some() { + return Ok(None); + } + candidate = Some(generated.id()); + } + } + Ok(candidate) + })?; + let body = Body::new(solid)?; + // tsweep retains each actual input boundary edge on its generated side. + // Match topology identity, never face order, surface samples or centroids. + let provenance = guard("extrude source identities", || { + let source_edges: std::collections::HashMap<_, _> = sources.iter().map(|(edge, segment)| (edge.id(), *segment)).collect(); + let mut result = Vec::new(); + for generated in body.solid.face_iter() { + let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect(); + let source = + if generated.id() == f.id() { + upper.and_then(|_| { + (r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::() <= 4096).then(|| { + crate::FaceSource::ExtrudeCap { + high: false, + region: r.clone(), + plane: Plane { origin: base.origin, x: plane.x, y: -plane.y }, + } + }) + }) + } else if Some(generated.id()) == upper { + (r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::() <= 4096) + .then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) }) + } else if let [segment] = matching.as_slice() { + let basis = match **segment { + Seg2::Line { a, b } => { + let d = b - a; + d.normalized().and_then(|direction| { + let foot = a - direction * a.dot(direction); + Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n) + }) + } + _ => None, + }; + Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis }) + } else { + None + }; + result.push(source); + } + Ok(result) + })?; + out.push(body.with_face_sources(Some(std::sync::Arc::new(provenance)))); } Ok(out) } -/// Revolve each region about the axis through sketch point `axis_origin` with sketch direction -/// `axis_dir` by `angle` radians (|angle| ≥ 2π = full revolution). +/// Choose the per-profile axis sign whose positive rotation follows the sketch normal. +pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2, axis_dir: Vec2) -> Result { + if !axis_origin.is_finite() || !axis_dir.is_finite() || !plane.origin.is_finite() || !plane.x.is_finite() || !plane.y.is_finite() { + return Err(KernelError::Invalid("non-finite revolve frame".into())); + } + if (plane.x.len2() - 1.).abs() > 1e-8 || (plane.y.len2() - 1.).abs() > 1e-8 || plane.x.dot(plane.y).abs() > 1e-8 { + return Err(KernelError::Invalid("revolve frame is not orthonormal".into())); + } + region_ok(region)?; + let direction = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?; + let axis = plane.dir_to_world(direction).normalized().ok_or_else(|| KernelError::Invalid("revolve frame has no axis".into()))?; + let origin = plane.to_world(axis_origin); + let center = plane.to_world(region.centroid()); + let radial = center - origin - axis * (center - origin).dot(axis); + Ok(if axis.cross(radial).dot(plane.normal()) < 0. { -axis } else { axis }) +} + +/// Revolve each region about the sketch axis by `angle` radians. +/// This legacy API preserves its historical sweep sign behavior. pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result> { let angle = finite(angle, "angle")?; if angle.abs() < 1e-6 { @@ -230,7 +337,36 @@ pub fn box_solid(a: Vec3, b: Vec3) -> Result { holes: vec![], }; let mut v = extrude(&Plane::XY, &[r], lo.z, hi.z)?; - v.pop().ok_or_else(|| KernelError::Failed("box".into())) + let body = v.pop().ok_or_else(|| KernelError::Failed("box".into()))?; + let sources = body.face_sources().ok_or_else(|| KernelError::Failed("box construction source".into()))?; + let mut roles = std::collections::HashSet::new(); + let mut certified = Vec::new(); + for source in sources { + let role = match source { + Some(crate::FaceSource::ExtrudeCap { high, .. }) => Some((2u8, *high)), + Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == lo.x => Some((0, false)), + Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == hi.x => Some((0, true)), + Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == lo.y => Some((1, false)), + Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == hi.y => Some((1, true)), + _ => None, + } + .ok_or_else(|| KernelError::Failed("box role correspondence".into()))?; + if !roles.insert(role) { + return Err(KernelError::Failed("duplicate box role".into())); + } + let (axis, high) = role; + let sign = if high { 1. } else { -1. }; + let plane = match axis { + 0 => Plane { origin: Vec3::X * if high { hi.x } else { lo.x }, x: Vec3::Y, y: Vec3::Z * sign }, + 1 => Plane { origin: Vec3::Y * if high { hi.y } else { lo.y }, x: Vec3::X, y: -Vec3::Z * sign }, + _ => Plane { origin: Vec3::Z * if high { hi.z } else { lo.z }, x: Vec3::X, y: Vec3::Y * sign }, + }; + certified.push(Some(crate::FaceSource::Box { producer: None, axis, high, plane })); + } + if roles.len() != 6 { + return Err(KernelError::Failed("box role count".into())); + } + Ok(body.with_face_sources(Some(std::sync::Arc::new(certified)))) } /// Where primitives start their circles (radians from the frame's x axis). 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/emboss.rs b/crates/kernel/src/emboss.rs new file mode 100644 index 00000000..806af16f --- /dev/null +++ b/crates/kernel/src/emboss.rs @@ -0,0 +1,730 @@ +//! Exact radial embossing of orthogonal profiles on complete cylindrical walls. +use crate::body::{Body, Solid, from_p3, p3}; +use crate::offset::{Surf, surf_of}; +use crate::{KernelError, Result, guard}; +use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; +use truck_modeling::{self as mt, Transformed, builder}; + +fn fail(s: &str) -> KernelError { + KernelError::Invalid(format!("cylinder emboss: {s}")) +} +fn angle(p: Vec3, o: Vec3, axis: Vec3, radial: Vec3, tangent: Vec3) -> f64 { + let w = p - o; + let w = w - axis * w.dot(axis); + w.dot(tangent).atan2(w.dot(radial)) +} +fn unwrap(a: f64, around: f64) -> f64 { + around + (a - around + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI +} + +fn wall_angles(face: &mt::Face, o: Vec3, axis: Vec3, radial: Vec3, tangent: Vec3, tol: f64) -> Option<(f64, f64)> { + if face.boundaries().len() != 1 || face.edge_iter().count() != 4 { + return None; + } + for edge in face.edge_iter() { + if let Some(Some(mid)) = crate::offset::arc_mid(&edge, tol * 10.) { + let am = angle(mid, o, axis, radial, tangent); + let aa = unwrap(angle(from_p3(edge.front().point()), o, axis, radial, tangent), am); + let ab = unwrap(angle(from_p3(edge.back().point()), o, axis, radial, tangent), am); + return Some((aa.min(ab), aa.max(ab))); + } + } + None +} + +/// Certify a tessellated pick and project it onto the cylinder used by emboss. +pub fn cylinder_emboss_anchor(body: &Body, face: usize, at: Vec3) -> Result { + if !at.is_finite() || at.len() > 1e9 { + return Err(fail("invalid selected face point")); + } + guard("cylinder emboss anchor", || { + let f = body.solid.face_iter().nth(face).ok_or_else(|| fail("selected face"))?; + let tol = (body.size() * 1e-8).max(1e-8); + let Some(Surf::Cylinder { o, a, r, convex: true }) = surf_of(f, tol) else { + return Err(fail("selected face is not an exterior cylinder")); + }; + let w = at - o; + let radial = w - a * w.dot(a); + if (radial.len() - r).abs() > (body.size() * 1e-3).max(1e-3).min(r * 0.05) { + return Err(fail("selected point is too far from cylinder")); + } + Ok(o + a * w.dot(a) + radial.normalized().ok_or_else(|| fail("radial point"))? * r) + }) +} + +/// Wrap sketch arc length around an exterior cylinder, retaining exact radial depth. +/// Profiles must contain orthogonal lines and lie inside one uninterrupted wall face. +pub fn emboss_cylinder(body: &Body, plane: &Plane, regions: &[Region2], depth: f64) -> Result { + emboss_impl(body, None, plane, regions, depth, false).and_then(EmbossOutcome::body) +} + +/// As [`emboss_cylinder`], requiring the saved selected point to identify that wall. +pub fn emboss_cylinder_at(body: &Body, at: Vec3, plane: &Plane, regions: &[Region2], depth: f64) -> Result { + if !at.is_finite() || at.len() > 1e9 { + return Err(fail("invalid selected face point")); + } + emboss_impl(body, Some(at), plane, regions, depth, false).and_then(EmbossOutcome::body) +} + +enum EmbossOutcome { + Body(Body), + Anchor(usize, Vec3), + NoMatch, +} +impl EmbossOutcome { + fn body(self) -> Result { + match self { + Self::Body(body) => Ok(body), + Self::Anchor(..) | Self::NoMatch => Err(fail("unexpected qualification result")), + } + } +} + +/// Qualify a tangent cylindrical target without changing it. A tangent exterior +/// cylinder with an unsupported profile or depth returns an explicit error; +/// a body without such a tangent wall returns `None` for planar compatibility. +/// The anchor lies on a validated profile boundary, clear of seams and endcaps. +pub fn infer_cylinder_emboss(body: &Body, plane: &Plane, regions: &[Region2], depth: f64) -> Result> { + if body.is_mesh() { + return Ok(None); + } + guard("cylinder emboss inference", || { + let tol = (body.size() * 1e-8).max(1e-8); + let mut tangent = false; + for (index, face) in body.solid.face_iter().enumerate() { + if index >= 50_000 { + return Err(fail("target search exceeds face work limit")); + } + if let Some(Surf::Cylinder { o, a, r, convex: true }) = surf_of(face, tol) { + let w = plane.origin - o; + let normal = plane.normal(); + let signed = w.dot(normal); + if normal.dot(a).abs() < 1e-8 && (signed.abs() - r).abs() <= tol * 10.0 { + let radial = if signed < 0.0 { -normal } else { normal }; + // Every supported uninterrupted wall uses one angle interval + // around this tangent normal, contained in (-pi,pi). A remote + // footprint entirely outside even that global interval cannot + // select this body, regardless of other unsupported faces. + if !profile_interval_overlaps(plane, regions, o, a.cross(radial), -std::f64::consts::PI * r, std::f64::consts::PI * r)? { + continue; + } + if let Some((lo, hi)) = wall_angles(face, o, a, radial, a.cross(radial), tol) { + if lo >= -tol / r || hi <= tol / r { + continue; + } + if !profile_interval_overlaps(plane, regions, o, a.cross(radial), lo * r, hi * r)? { + continue; + } + } + let relevant = match face_axial_bounds(face, o, a)? { + Some((lo, hi)) => profile_interval_overlaps(plane, regions, o, a, lo, hi)?, + None => true, + }; + if relevant && signed < 0.0 { + return Err(fail("sketch normal must point outwards from the exterior wall")); + } + tangent |= relevant; + } + } + } + if !tangent { + return Ok(None); + } + match emboss_impl(body, None, plane, regions, depth, true)? { + EmbossOutcome::Anchor(index, at) => Ok(Some((index, at))), + EmbossOutcome::NoMatch => Ok(None), + EmbossOutcome::Body(_) => Err(fail("unexpected construction result")), + } + }) +} + +/// Whether a body contains an analytic exterior cylindrical wall. This bounded +/// preflight avoids resolving cylinder placement context for ordinary planar targets. +pub fn has_cylinder_emboss_wall(body: &Body) -> Result { + if body.is_mesh() { + return Ok(false); + } + guard("cylinder emboss target", || { + let tol = (body.size() * 1e-8).max(1e-8); + for (index, face) in body.solid.face_iter().enumerate() { + if index >= 50_000 { + return Err(fail("target search exceeds face work limit")); + } + if matches!(surf_of(face, tol), Some(Surf::Cylinder { convex: true, .. })) { + return Ok(true); + } + } + Ok(false) + }) +} +fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Region2], depth: f64, qualify: bool) -> Result { + body.require_brep("cylinder emboss")?; + if !depth.is_finite() || depth.abs() < 1e-6 || depth.abs() > 1e6 || regions.is_empty() || regions.len() > 64 { + return Err(fail("needs finite nonzero depth and 1..64 profiles")); + } + if !plane.origin.is_finite() + || !plane.x.is_finite() + || !plane.y.is_finite() + || (plane.x.len() - 1.0).abs() > 1e-8 + || (plane.y.len() - 1.0).abs() > 1e-8 + || plane.x.dot(plane.y).abs() > 1e-8 + { + return Err(fail("invalid sketch frame")); + } + guard("cylinder emboss", || { + if body.solid.boundaries().len() != 1 || body.solid.face_iter().take(17).count() > 16 || body.solid.edge_iter().take(257).count() > 256 { + return Err(fail("requires a complete cylinder or coaxial sleeve")); + } + let faces: Vec<_> = body.solid.face_iter().cloned().collect(); + let tol = (body.size() * 1e-8).max(1e-8); + let surfaces: Vec<_> = + faces.iter().map(|f| surf_of(f, tol).ok_or_else(|| fail("requires only cylindrical walls and planar endcaps"))).collect::>()?; + let mut chosen = None; + for (fi, s) in surfaces.iter().enumerate() { + let Surf::Cylinder { o, a, r, convex: true } = *s else { continue }; + let w = plane.origin - o; + let radial = plane.normal(); + if radial.dot(a).abs() > 1e-8 || (w.dot(radial) - r).abs() > tol * 10.0 { + continue; + } + let radial = radial.normalized().ok_or_else(|| fail("radial frame"))?; + let tangent = a.cross(radial); + let f = faces.get(fi).ok_or_else(|| fail("face"))?; + if f.boundaries().len() != 1 || f.edge_iter().count() != 4 { + continue; + } + let Some((alo, ahi)) = wall_angles(f, o, a, radial, tangent, tol) else { + continue; + }; + if alo >= -tol / r || ahi <= tol / r { + continue; + } + if chosen.is_some() { + return Err(fail("ambiguous cylindrical face")); + } + chosen = Some((fi, o, a, r, radial, tangent, alo, ahi)); + } + let (fi, o, axis, r, radial, tangent, alo, ahi) = + chosen.ok_or_else(|| fail("sketch must be tangent to one complete exterior cylindrical wall, clear of its seam"))?; + if qualify && !profile_interval_overlaps(plane, regions, o, tangent, alo * r, ahi * r)? { + return Ok(EmbossOutcome::NoMatch); + } + let mut caps = Vec::new(); + let mut inner: f64 = 0.0; + for s in &surfaces { + match *s { + Surf::Plane { n, d } if n.dot(axis).abs() > 1.0 - 1e-8 => caps.push((d - n.dot(o)) / n.dot(axis)), + Surf::Cylinder { o: co, a: ca, r: cr, convex } + if ca.dot(axis).abs() > 1.0 - 1e-8 && (co - o - axis * (co - o).dot(axis)).len() < tol * 10.0 => + { + if convex && (cr - r).abs() > tol * 10.0 { + return Err(fail("interrupted or stepped cylinder")); + } + if !convex { + inner = inner.max(cr); + } + } + _ => return Err(fail("requires a complete cylinder or coaxial sleeve")), + } + } + if caps.len() != 2 { + return Err(fail("requires two planar endcaps")); + } + let lo = caps.iter().copied().fold(f64::INFINITY, f64::min); + let hi = caps.iter().copied().fold(f64::NEG_INFINITY, f64::max); + // An infinite cylinder can be tangent while its finite wall is far from + // the selected footprint. Conservative analytic/control-polygon bounds + // exclude that body before unsupported-profile or depth diagnostics. + if qualify && !profile_interval_overlaps(plane, regions, o, axis, lo, hi)? { + return Ok(EmbossOutcome::NoMatch); + } + let rr = r + depth; + if rr <= inner + tol * 10.0 { + return Err(fail("depth reaches the axis or inner wall")); + } + if let Some(at) = anchor { + let h = (at - o).dot(axis); + let v = at - o - axis * h; + let theta = angle(at, o, axis, radial, tangent); + if (v.len() - r).abs() > tol * 10.0 || theta <= alo + tol / r || theta >= ahi - tol / r || h <= lo + tol || h >= hi - tol { + return Err(fail("saved selection no longer identifies the sketch's cylindrical face")); + } + } + let z0 = (plane.origin - o).dot(axis); + let s0 = (plane.origin - o).dot(tangent); + let map2 = |p: Vec2| Vec2::new(s0 + plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis)); + let mut count = 0usize; + let mut mapped = Vec::new(); + for reg in regions { + let mut loops = Vec::new(); + for lp in std::iter::once(®.outer).chain(®.holes) { + count = count.saturating_add(lp.segs.len()); + if count > 4096 { + return Err(fail("profile geometry budget")); + } + if lp.segs.iter().any(|segment| !matches!(segment, Seg2::Line { .. })) { + return Err(fail("only orthogonal line profiles are supported on cylinders")); + } + if lp.segs.len() < 4 || !lp.is_closed(tol) { + return Err(fail("profile geometry budget or open loop")); + } + let mut segs = Vec::new(); + for s in &lp.segs { + let Seg2::Line { a, b } = *s else { + return Err(fail("only orthogonal line profiles are supported on cylinders")); + }; + if !a.is_finite() || !b.is_finite() || a.len() > 1e7 || b.len() > 1e7 { + return Err(fail("nonfinite or excessive profile coordinates")); + } + let (a, b) = (map2(a), map2(b)); + let d = b - a; + if d.len() < tol || (d.x.abs() > tol && d.y.abs() > tol) { + return Err(fail("profile edges must follow the cylinder axis or circumference")); + } + for p in [a, b] { + if p.x / r <= alo + tol / r || p.x / r >= ahi - tol / r || p.y <= lo + tol || p.y >= hi - tol { + return Err(fail("profile crosses a cylinder seam or endcap boundary")); + } + } + segs.push(Seg2::Line { a, b }); + } + loops.push(Loop2 { segs }); + } + let outer = loops.first().ok_or_else(|| fail("profile"))?.ccw(); + let holes = loops.iter().skip(1).map(|l| l.ccw().reversed()).collect(); + mapped.push(Region2 { outer, holes }); + } + validate_profiles(&mapped, tol)?; + if qualify { + let segment = mapped.first().and_then(|region| region.outer.segs.first()).ok_or_else(|| fail("empty profile"))?; + let p = (segment.start() + segment.end()) * 0.5; + let at = o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * r; + return Ok(EmbossOutcome::Anchor(fi, at)); + } + let at = |p: Vec2, radius: f64| o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * radius; + let selected = faces.get(fi).ok_or_else(|| fail("face"))?; + let mut base_patches = vec![selected.absolute_boundaries().clone()]; + let mut hole_patches = Vec::new(); + let mut footprints = Vec::new(); + let mut added = Vec::new(); + let k = rr / r; + let coord = |v: Vec3| [v.x, v.y, v.z]; + let av = coord(axis); + let ov = coord(o); + let mut m = [[0.0; 4]; 4]; + for (j, col) in m.iter_mut().take(3).enumerate() { + for (i, slot) in col.iter_mut().take(3).enumerate() { + *slot = if i == j { k } else { 0.0 } + (1.0 - k) * av.get(i).copied().unwrap_or(0.0) * av.get(j).copied().unwrap_or(0.0); + } + } + for (i, oi) in ov.iter().enumerate() { + let translation = + *oi - (0..3).map(|j| m.get(j).and_then(|row| row.get(i)).copied().unwrap_or(0.0) * ov.get(j).copied().unwrap_or(0.0)).sum::(); + if let Some(slot) = m.get_mut(3).and_then(|row| row.get_mut(i)) { + *slot = translation; + } + } + if let Some(slot) = m.get_mut(3).and_then(|row| row.get_mut(3)) { + *slot = 1.0; + } + let [c0, c1, c2, c3] = m; + let [a, b, c, d] = c0; + let [e, f, g, h] = c1; + let [i, j, l, n] = c2; + let [p, q, s, t] = c3; + let mat = mt::Matrix4::new(a, b, c, d, e, f, g, h, i, j, l, n, p, q, s, t); + let raised = selected.oriented_surface().transformed(mat); + for reg in &mapped { + let mut cap = Vec::new(); + for (li, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { + let (base, top, walls) = wrapped_loop(lp, r, rr, &at)?; + if li == 0 { + footprints.push((lp, base.inverse())); + } else { + let index = base_patches.len(); + base_patches.push(vec![base.inverse()]); + hole_patches.push((lp, index)); + } + cap.push(top); + added.extend(walls); + } + added.push(mt::Face::try_new(cap, raised.clone()).map_err(|e| fail(&e.to_string()))?); + } + // An island inside a profile hole cuts that hole's original-radius patch, + // rather than adding another hole to the already-cut exterior patch. + for (lp, wire) in footprints { + let probe = lp.segs.first().ok_or_else(|| fail("empty profile"))?.start(); + let owner = hole_patches + .iter() + .filter(|(hole, _)| hole.contains(probe)) + .min_by(|(a, _), (b, _)| a.signed_area().abs().total_cmp(&b.signed_area().abs())) + .map(|(_, index)| *index) + .unwrap_or(0); + base_patches.get_mut(owner).ok_or_else(|| fail("profile ownership"))?.push(wire); + } + let mut all: Vec<_> = faces.iter().enumerate().filter(|(i, _)| *i != fi).map(|(_, f)| f.clone()).collect(); + for patch in base_patches { + all.push(mt::Face::try_new(patch, selected.oriented_surface()).map_err(|e| fail(&e.to_string()))?); + } + all.extend(added); + let solid = Solid::try_new(vec![all.into()]).map_err(|e| fail(&e.to_string()))?; + let result = Body::new(solid)?; + if !result.validity().is_empty() { + return Err(fail("rebuilt cylinder is not a closed manifold")); + } + Ok(EmbossOutcome::Body(result)) + }) +} + +fn profile_interval_overlaps(plane: &Plane, regions: &[Region2], o: Vec3, axis: Vec3, lo: f64, hi: f64) -> Result { + let z0 = (plane.origin - o).dot(axis); + let direction = Vec2::new(plane.x.dot(axis), plane.y.dot(axis)); + let mut count = 0usize; + let mut overlaps = false; + for region in regions { + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for segment in ®ion.outer.segs { + count = count.saturating_add(1); + if count > 4096 { + return Err(fail("profile geometry budget")); + } + let mut add = |p: Vec2| -> Result<()> { + if !p.is_finite() || p.len() > 1e7 { + return Err(fail("nonfinite or excessive profile coordinates")); + } + let z = z0 + p.dot(direction); + min = min.min(z); + max = max.max(z); + Ok(()) + }; + match *segment { + Seg2::Line { a, b } => { + add(a)?; + add(b)?; + } + Seg2::Arc { center, radius, .. } => { + add(center)?; + if !radius.is_finite() || radius <= 0.0 || radius > 1e7 { + return Err(fail("invalid profile radius")); + } + let z = z0 + center.dot(direction); + min = min.min(z - radius * direction.len()); + max = max.max(z + radius * direction.len()); + } + Seg2::Cubic { p0, p1, p2, p3 } => { + for p in [p0, p1, p2, p3] { + add(p)?; + } + } + Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => { + for p in [a, apex, b] { + add(p)?; + } + } + Seg2::Bezier { n, p } if (1..=7).contains(&n) => { + for p in p.iter().take(usize::from(n) + 1) { + add(*p)?; + } + } + _ => return Err(fail("unsupported profile geometry")), + } + } + overlaps |= min < hi && max > lo; + } + Ok(overlaps) +} + +fn face_axial_bounds(face: &mt::Face, o: Vec3, axis: Vec3) -> Result> { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + let mut count = 0usize; + let mut add = |point: Vec3| -> Result<()> { + count = count.saturating_add(1); + if count > 100_000 || !point.is_finite() { + return Err(fail("target boundary work limit or nonfinite geometry")); + } + let z = (point - o).dot(axis); + lo = lo.min(z); + hi = hi.max(z); + Ok(()) + }; + for edge in face.edge_iter() { + match edge.curve() { + mt::Curve::Line(_) => { + add(from_p3(edge.front().point()))?; + add(from_p3(edge.back().point()))?; + } + mt::Curve::BSplineCurve(curve) => { + for point in curve.control_points() { + add(from_p3(*point))?; + } + } + mt::Curve::NurbsCurve(curve) => { + for point in curve.control_points() { + if !point.w.is_finite() || point.w <= 0.0 { + return Ok(None); + } + add(Vec3::new(point.x / point.w, point.y / point.w, point.z / point.w))?; + } + } + mt::Curve::IntersectionCurve(_) => return Ok(None), + } + } + Ok((lo.is_finite() && hi.is_finite()).then_some((lo, hi))) +} + +fn wrapped_loop(lp: &Loop2, r: f64, rr: f64, at: &impl Fn(Vec2, f64) -> Vec3) -> Result<(mt::Wire, mt::Wire, Vec)> { + let base: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), r)))).collect(); + let top: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), rr)))).collect(); + let radial: Vec<_> = base.iter().zip(&top).map(|(a, b)| builder::line(a, b)).collect(); + let mut be = Vec::new(); + let mut te = Vec::new(); + let mut walls = Vec::new(); + for (i, s) in lp.segs.iter().enumerate() { + let next = (i + 1) % lp.segs.len(); + let (Some(a), Some(b), Some(c), Some(d), Some(ra), Some(rb)) = + (base.get(i), base.get(next), top.get(i), top.get(next), radial.get(i), radial.get(next)) + else { + return Err(fail("loop topology")); + }; + let circular = (s.end().x - s.start().x).abs() > 1e-10; + let eb = if circular { builder::circle_arc(a, b, p3(at(s.mid(), r))) } else { builder::line(a, b) }; + let et = if circular { builder::circle_arc(c, d, p3(at(s.mid(), rr))) } else { builder::line(c, d) }; + let w: mt::Wire = vec![eb.clone(), rb.clone(), et.inverse(), ra.inverse()].into(); + walls.push(builder::try_attach_plane(&[w]).map_err(|e| fail(&e.to_string()))?); + be.push(eb); + te.push(et); + } + Ok((be.into(), te.into(), walls)) +} + +fn validate_profiles(regions: &[Region2], tol: f64) -> Result<()> { + let loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).collect(); + let edges: Vec<_> = loops.iter().enumerate().flat_map(|(l, lp)| lp.segs.iter().enumerate().map(move |(i, s)| (l, i, *s))).collect(); + for (k, (l, i, s)) in edges.iter().enumerate() { + for (ll, j, t) in edges.iter().skip(k + 1) { + let len = loops.get(*l).map(|lp| lp.segs.len()).unwrap_or(0); + if l == ll && ((*i + 1) % len == *j || (*j + 1) % len == *i) { + continue; + } + let (a, b, c, d) = (s.start(), s.end(), t.start(), t.end()); + let ab = b - a; + let cd = d - c; + let cross = ab.cross(cd); + let intersects = if cross.abs() > tol * tol { + let u = (c - a).cross(cd) / cross; + let v = (c - a).cross(ab) / cross; + (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v) + } else { + false + }; + if intersects + || [a, b].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) < tol) + || [c, d].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) < tol) + { + return Err(fail("profile boundaries intersect or touch")); + } + } + } + for (i, r) in regions.iter().enumerate() { + if r.area() <= tol * tol { + return Err(fail("profile has no area")); + } + for (h, lp) in r.holes.iter().enumerate() { + if !r.outer.contains(lp.segs.first().ok_or_else(|| fail("empty hole"))?.start()) { + return Err(fail("hole outside profile")); + } + for other in r.holes.iter().skip(h + 1) { + if lp.contains(other.segs.first().ok_or_else(|| fail("empty hole"))?.start()) + || other.contains(lp.segs.first().ok_or_else(|| fail("empty hole"))?.start()) + { + return Err(fail("nested profile holes")); + } + } + } + for other in regions.iter().skip(i + 1) { + if r.contains(other.outer.segs.first().ok_or_else(|| fail("empty profile"))?.start()) + || other.contains(r.outer.segs.first().ok_or_else(|| fail("empty profile"))?.start()) + { + return Err(fail("overlapping profiles")); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn profile() -> Region2 { + Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 5.), Vec2::new(2., 5.), Vec2::new(2., 10.), Vec2::new(-2., 10.)]), holes: vec![] } + } + fn plane() -> Plane { + Plane::new(Vec3::Y * 10., -Vec3::X, Vec3::Z).unwrap() + } + #[test] + fn inference_uses_finite_wall_footprint_and_an_interior_profile_anchor() { + let near = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + let far = crate::cylinder(Vec3::Z * 100., Vec3::Z, 10., 20.).unwrap(); + let other = crate::cylinder(Vec3::Z * 120., Vec3::Z, 8., 10.).unwrap(); + let unsupported = crate::ops::join_pieces(vec![far.clone(), other]).unwrap().unwrap(); + let regions = [profile()]; + for body in [&far, &unsupported] { + assert!(infer_cylinder_emboss(body, &plane(), ®ions, 1.).unwrap().is_none()); + } + for x in [20., 80.] { + let base = crate::cylinder(Vec3::X * x, Vec3::Z, 10., 20.).unwrap(); + let step = crate::cylinder(Vec3::X * x + Vec3::Z * 20., Vec3::Z, 8., 10.).unwrap(); + let remote = crate::ops::join_pieces(vec![base, step]).unwrap().unwrap(); + let inferred = infer_cylinder_emboss(&remote, &plane(), ®ions, 1.); + if x == 20. { + // The offset maps this footprint across the remote wall's seam; + // partial relevance must retain conservative unsupported refusal. + assert!(inferred.is_err()); + } else { + assert!(inferred.unwrap().is_none()); + } + } + let (index, anchor) = infer_cylinder_emboss(&near, &plane(), ®ions, 1.).unwrap().unwrap(); + assert!(anchor.z > 0. && anchor.z < 20.); + assert!((anchor.x.hypot(anchor.y) - 10.).abs() < 1e-9); + assert!(cylinder_emboss_anchor(&near, index, anchor).unwrap().dist(anchor) < 1e-10); + let shifted = Plane::new(Vec3::new(-3., 10., 0.), -Vec3::X, Vec3::Z).unwrap(); + let shifted_profile = + Region2 { outer: Loop2::polygon(&[Vec2::new(-5., 5.), Vec2::new(-1., 5.), Vec2::new(-1., 10.), Vec2::new(-5., 10.)]), holes: vec![] }; + let (_, other_anchor) = infer_cylinder_emboss(&near, &shifted, std::slice::from_ref(&shifted_profile), 1.).unwrap().unwrap(); + assert!(other_anchor.dist(anchor) < 1e-10); + let before = near.tessellate_with(1e-4, true).unwrap().measure().volume; + let result = emboss_cylinder_at(&near, other_anchor, &shifted, &[shifted_profile], 1.).unwrap(); + assert!((result.tessellate_with(1e-4, true).unwrap().measure().volume - before - 21.).abs() < 0.02); + assert!(infer_cylinder_emboss(&near, &plane(), ®ions, -10.).is_err()); + let inward = Plane::new(Vec3::Y * 10., Vec3::X, Vec3::Z).unwrap(); + assert!(infer_cylinder_emboss(&near, &inward, ®ions, 1.).unwrap_err().to_string().contains("outwards")); + } + #[test] + fn wrapped_nested_island_uses_its_enclosing_hole_patch() { + let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + let mut ring = profile(); + ring.holes.push(Loop2::polygon(&[Vec2::new(-1., 6.), Vec2::new(1., 6.), Vec2::new(1., 9.), Vec2::new(-1., 9.)])); + let island = + Region2 { outer: Loop2::polygon(&[Vec2::new(-0.5, 6.5), Vec2::new(0.5, 6.5), Vec2::new(0.5, 8.5), Vec2::new(-0.5, 8.5)]), holes: vec![] }; + let before = body.tessellate_with(1e-4, true).unwrap().measure().volume; + for depth in [1., -1.] { + for regions in [vec![ring.clone(), island.clone()], vec![island.clone(), ring.clone()]] { + let result = emboss_cylinder(&body, &plane(), ®ions, depth).unwrap(); + assert_eq!(result.shell_count(), 1); + assert!(result.validity().is_empty()); + let after = result.tessellate_with(1e-4, true).unwrap().measure().volume; + let expected = 16. * (depth + depth * depth / 20.); + assert!((after - before - expected).abs() < 0.02, "{} vs {expected}", after - before); + let mesh = result.tessellate_with(0.001, true).unwrap(); + for (s, raised) in [(0., true), (0.75, false), (1.5, true)] { + let radial = -Vec3::X * (s / 10.0_f64).sin() + Vec3::Y * (s / 10.0_f64).cos(); + let radius = if depth > 0. { 10.5 } else { 9.5 }; + assert_eq!(mesh.contains(radial * radius + Vec3::Z * 7.5), if depth > 0. { raised } else { !raised }); + } + } + } + } + + #[test] + fn exact_wrapped_rectangle_is_connected_and_has_radial_depth() { + let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + for depth in [1., -1.] { + let result = emboss_cylinder(&body, &plane(), &[profile()], depth).unwrap(); + assert_eq!(result.shell_count(), 1); + assert!(result.validity().is_empty()); + let before = body.tessellate_with(1e-4, true).unwrap().measure().volume; + let after = result.tessellate_with(1e-4, true).unwrap().measure().volume; + let expected = if depth > 0. { 21. } else { -19. }; + assert!((after - before - expected).abs() < 0.02, "{} vs {expected}", after - before); + let mesh = result.tessellate_with(0.001, true).unwrap(); + for s in [-1.5, 0., 1.5] { + let radial = -Vec3::X * (s / 10.0_f64).sin() + Vec3::Y * (s / 10.0_f64).cos(); + assert_eq!(mesh.contains(radial * (if depth > 0. { 10.5 } else { 9.5 }) + Vec3::Z * 7.5), depth > 0.); + } + } + } + + #[test] + fn wrapped_holes_and_concave_profiles_preserve_material_on_rotated_axes() { + let origin = Vec3::new(123.0, -456.0, 789.0); + let axis = Vec3::X; + let radial = Vec3::Y; + let tangent = Vec3::Z; + let body = crate::cylinder(origin, axis, 10.0, 20.0).unwrap(); + let plane = Plane::new(origin + radial * 10.0, tangent, axis).unwrap(); + let mut holed = profile(); + holed.holes.push(Loop2::polygon(&[Vec2::new(-1., 6.), Vec2::new(1., 6.), Vec2::new(1., 9.), Vec2::new(-1., 9.)])); + let concave = Region2 { + outer: Loop2::polygon(&[ + Vec2::new(-2., 5.), + Vec2::new(2., 5.), + Vec2::new(2., 7.), + Vec2::new(0., 7.), + Vec2::new(0., 10.), + Vec2::new(-2., 10.), + ]), + holes: vec![], + }; + for profile in [holed, concave] { + for depth in [1., -1.] { + let result = emboss_cylinder_at(&body, origin + radial * 10.0 + axis * 7.5, &plane, std::slice::from_ref(&profile), depth).unwrap(); + assert_eq!(result.shell_count(), 1); + let before = body.tessellate_with(1e-4, true).unwrap().measure().volume; + let after = result.tessellate_with(1e-4, true).unwrap().measure().volume; + let want = profile.area().abs() * (depth + depth * depth / 20.0); + assert!((after - before - want).abs() < 0.02, "{} vs {want}", after - before); + let point = |s: f64, z: f64, r: f64| origin + axis * z + (radial * (s / 10.0).cos() + tangent * (s / 10.0).sin()) * r; + let mesh = result.tessellate_with(0.001, true).unwrap(); + for (s, z) in [(-1.5, 7.5), (1.5, 7.5), (0.5, 6.5)] { + let material = profile.contains(Vec2::new(s, z)); + assert_eq!(mesh.contains(point(s, z, if depth > 0. { 10.5 } else { 9.5 })), if depth > 0. { material } else { !material }); + } + let imported = crate::step_import(&crate::step_export(&[&result], "wrapped cylinder").unwrap()).unwrap(); + assert!(imported.warnings.is_empty(), "{:?}", imported.warnings); + assert_eq!(imported.bodies.len(), 1); + let round = imported.bodies.first().unwrap(); + assert!(round.body.validity().is_empty()); + let roundmesh = round.body.tessellate_with(0.001, true).unwrap(); + assert_eq!(roundmesh.contains(point(-1.5, 7.5, if depth > 0. { 10.5 } else { 9.5 })), depth > 0.); + let cut = crate::boolean( + &round.body, + &crate::box_solid(origin + Vec3::new(5., -20., -20.), origin + Vec3::new(10., 20., 20.)).unwrap(), + crate::BoolOp::Cut, + ) + .unwrap() + .unwrap(); + assert!(!cut.tessellate(0.01).unwrap().contains(point(-1.5, 7.5, 9.0))); + } + } + } + + #[test] + fn sleeve_depth_and_hostile_profiles_refuse_changes_atomically() { + let outer = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + let inner = crate::cylinder(-Vec3::Z, Vec3::Z, 8., 22.).unwrap(); + let sleeve = crate::boolean(&outer, &inner, crate::BoolOp::Cut).unwrap().unwrap(); + let good = emboss_cylinder(&sleeve, &plane(), &[profile()], -1.).unwrap(); + assert_eq!(good.shell_count(), 1); + let before = crate::step_export(&[&sleeve], "unchanged").unwrap(); + let mut cap = profile(); + cap.outer = Loop2::polygon(&[Vec2::new(-2., 19.), Vec2::new(2., 19.), Vec2::new(2., 21.), Vec2::new(-2., 21.)]); + let circle = Region2 { outer: Loop2::circle(Vec2::new(0., 7.5), 1.), holes: vec![] }; + let mut crossing = profile(); + crossing.outer = Loop2::polygon(&[Vec2::new(-2., 5.), Vec2::new(2., 10.), Vec2::new(-2., 10.), Vec2::new(2., 5.)]); + for (profiles, d) in [ + (vec![profile()], -2.), + (vec![cap], 1.), + (vec![circle], 1.), + (vec![crossing], 1.), + (vec![profile()], f64::NAN), + (vec![profile(); 65], 1.), + ] { + assert!(emboss_cylinder(&sleeve, &plane(), &profiles, d).is_err()); + } + assert!(emboss_cylinder_at(&sleeve, -Vec3::Y * 10. + Vec3::Z * 7.5, &plane(), &[profile()], 1.).is_err()); + assert_eq!(crate::step_export(&[&sleeve], "unchanged").unwrap(), before); + } +} diff --git a/crates/kernel/src/face_source_tests.rs b/crates/kernel/src/face_source_tests.rs new file mode 100644 index 00000000..927750b6 --- /dev/null +++ b/crates/kernel/src/face_source_tests.rs @@ -0,0 +1,297 @@ +use crate::{FaceSource, extrude, transform_matrix}; +use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +fn assert_exact_planes(body: &crate::Body) { + let mesh = body.tessellate(0.001).unwrap(); + for (index, _) in body.face_sources().unwrap().iter().enumerate() { + let Some(plane) = body.face_plane(index) else { continue }; + let mut count = 0; + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if *face as usize != index { + continue; + } + for point in mesh.tri(triangle).unwrap() { + assert!((point - plane.origin).dot(plane.normal()).abs() < 1e-7); + count += 1; + } + } + assert!(count > 0); + let normal = body.faces(0.001).unwrap()[index].plane_normal.unwrap(); + assert!(normal.dot(plane.normal()) > 0.999999); + } +} + +#[test] +fn box_certificates_are_six_construction_roles_with_dimension_stable_axes() { + for (a, b) in [ + (Vec3::new(2., 3., 4.), Vec3::new(12., 23., 34.)), + (Vec3::new(12., 23., 34.), Vec3::new(2., 3., 4.)), + (Vec3::new(2., -8., 4.), Vec3::new(22., 43., 54.)), + ] { + let body = crate::box_solid(a, b).unwrap().with_box_certificate(42).unwrap(); + let mut roles = std::collections::HashSet::new(); + for (index, source) in body.face_sources().unwrap().iter().enumerate() { + let Some(FaceSource::Box { producer, axis, high, plane }) = source else { panic!("uncertified box face") }; + assert_eq!(*producer, Some(42)); + assert!(roles.insert((*axis, *high))); + let unit = [Vec3::X, Vec3::Y, Vec3::Z][*axis as usize]; + let bound = if *high { a.max(b) } else { a.min(b) }; + assert_eq!(plane.origin, unit * bound.dot(unit)); + assert!(plane.normal().dist(unit * if *high { 1. } else { -1. }) < 1e-12); + assert_eq!(body.face_plane(index), Some(*plane)); + } + assert_eq!(roles.len(), 6); + assert_exact_planes(&body); + } +} + +#[test] +fn cap_roles_follow_exact_offset_planes_and_canonical_profile_memberships() { + let outer = Loop2::polygon(&[Vec2::new(-10., -10.), Vec2::new(10., -10.), Vec2::new(10., 10.), Vec2::new(-10., 10.)]); + let h1 = Loop2::circle(Vec2::new(-4., 0.), 1.); + let h2 = Loop2::circle(Vec2::new(4., 0.), 2.); + let plane = Plane::new(Vec3::new(30., 40., 50.), Vec3::Y, Vec3::Z).unwrap(); + for (start, end) in [(-9., -2.), (7., -3.), (2., 11.)] { + for holes in [vec![h1.clone(), h2.clone()], vec![h2.clone().reversed(), h1.clone().reversed()]] { + let region = Region2 { outer: outer.clone().reversed(), holes }; + let raw = extrude(&plane, std::slice::from_ref(®ion), start, end).unwrap().remove(0); + let signatures: Vec<_> = raw + .face_sources() + .unwrap() + .iter() + .map(|source| match source { + Some(FaceSource::ExtrudeCap { .. }) => { + Some((vec!["l4".into(), "l1".into(), "l3".into(), "l2".into()], vec![vec!["c2".into()], vec!["c1".into()]])) + } + _ => None, + }) + .collect(); + let body = raw.with_extrude_cap_certificates(42, 11, &signatures).unwrap(); + let mut caps = std::collections::HashSet::new(); + for source in body.face_sources().unwrap().iter().flatten() { + if let FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane: cap } = source { + assert_eq!((*producer, *sketch), (42, 11)); + assert!(caps.insert(*high)); + assert_eq!(outer, &["l1", "l2", "l3", "l4"]); + assert_eq!(holes, &[vec!["c1"], vec!["c2"]]); + assert!(cap.origin.dist(plane.origin + plane.normal() * if *high { start.max(end) } else { start.min(end) }) < 1e-10); + assert!(cap.normal().dot(plane.normal()) * if *high { 1. } else { -1. } > 0.999999); + } + } + assert_eq!(caps.len(), 2); + assert_exact_planes(&body); + let expected = (400. - 5. * std::f64::consts::PI) * (end - start).abs(); + assert!((body.tessellate(1e-4).unwrap().measure().volume - expected).abs() < 0.03); + } + } +} + +#[test] +fn construction_planes_follow_reflected_and_sheared_affine_images() { + let body = crate::box_solid(Vec3::new(2., 3., 4.), Vec3::new(12., 23., 34.)).unwrap().with_box_certificate(42).unwrap(); + for m in [ + [[0., 2., 0., 0.], [-1., 0., 0., 0.], [0., 0., 3., 0.], [25., 30., 40., 1.]], + [[-1., 0., 0., 0.], [0.4, 2., 0., 0.], [0., 0.3, 1., 0.], [8., 9., 10., 1.]], + ] { + let placed = transform_matrix(&body, m).unwrap(); + for (original, transformed) in body.face_sources().unwrap().iter().zip(placed.face_sources().unwrap()) { + let ( + Some(FaceSource::Box { producer: a, axis: aa, high: ah, plane: ap }), + Some(FaceSource::Box { producer: b, axis: ba, high: bh, plane: bp }), + ) = (original, transformed) + else { + panic!("box identity changed") + }; + assert_eq!((a, aa, ah), (b, ba, bh)); + let p = ap.origin; + let expected = Vec3::new( + m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0], + m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + m[3][1], + m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + m[3][2], + ); + assert!(bp.origin.dist(expected) < 1e-10); + } + assert_exact_planes(&placed); + } +} + +#[test] +fn material_changes_discard_producer_certificates() { + let base = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(42).unwrap(); + let tool = crate::box_solid(Vec3::new(5., -1., -1.), Vec3::new(11., 11., 11.)).unwrap(); + let cut = crate::boolean(&base, &tool, crate::BoolOp::Cut).unwrap().unwrap(); + assert!((cut.tessellate(1e-4).unwrap().measure().volume - 500.).abs() < 1e-8); + assert!(cut.face_sources().into_iter().flatten().flatten().all(|source| matches!( + source, + FaceSource::ExtrudeSide { .. } + | FaceSource::ExtrudeCap { .. } + | FaceSource::Box { producer: None, .. } + | FaceSource::Imported { producer: None, .. } + ))); +} + +#[test] +fn canonical_revolve_axis_is_per_profile_and_independent_of_input_axis_sign() { + for x in [-4., 4.] { + let region = Region2 { + outer: Loop2::polygon(&[Vec2::new(x - 1., 0.), Vec2::new(x + 1., 0.), Vec2::new(x + 1., 2.), Vec2::new(x - 1., 2.)]), + holes: vec![], + }; + let axis = crate::canonical_revolve_axis(&Plane::XY, ®ion, Vec2::default(), Vec2::Y).unwrap(); + assert_eq!(axis, crate::canonical_revolve_axis(&Plane::XY, ®ion, Vec2::default(), -Vec2::Y).unwrap()); + assert!(axis.cross(Plane::XY.to_world(region.centroid())).dot(Vec3::Z) > 0.); + assert_eq!(axis, Vec3::Y * if x < 0. { 1. } else { -1. }); + } +} + +#[test] +fn linear_side_frame_stays_fixed_when_endpoints_and_depth_change() { + for (lo, hi, start, end) in [(3., 23., 0., 7.), (-9., 43., -3., 19.)] { + let region = + Region2 { outer: Loop2::polygon(&[Vec2::new(2., lo), Vec2::new(12., lo), Vec2::new(12., hi), Vec2::new(2., hi)]), holes: vec![] }; + let body = extrude(&Plane::XY, &[region], start, end).unwrap().remove(0); + let plane = body + .face_sources() + .unwrap() + .iter() + .enumerate() + .find_map(|(index, source)| match source { + Some(FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == 2. && b.x == 2. => body.face_plane(index), + _ => None, + }) + .unwrap(); + assert_eq!(plane.origin, Vec3::X * 2.); + assert_eq!(plane.x, -Vec3::Y); + assert_eq!(plane.normal(), -Vec3::X); + } +} + +#[test] +fn reflected_hole_walls_and_low_caps_keep_exact_outward_planes() { + let outer = Loop2::polygon(&[Vec2::new(-10., -10.), Vec2::new(10., -10.), Vec2::new(10., 10.), Vec2::new(-10., 10.)]); + let hole = Loop2::polygon(&[Vec2::new(-3., -2.), Vec2::new(3., -2.), Vec2::new(3., 2.), Vec2::new(-3., 2.)]); + let body = extrude(&Plane::XY, &[Region2 { outer, holes: vec![hole] }], -4., 7.).unwrap().remove(0); + let reflected = transform_matrix(&body, [[-1., 0., 0., 0.], [0., 2., 0., 0.], [0.2, 0., 1., 0.], [30., 40., 50., 1.]]).unwrap(); + assert_exact_planes(&reflected); + assert_eq!( + reflected.face_sources().unwrap().iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { plane: Some(_), .. }))).count(), + 8 + ); + assert_eq!( + reflected.face_sources().unwrap().iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { high: false, .. }))).count(), + 1 + ); +} + +#[test] +fn side_basis_and_attached_coordinates_stay_continuous_through_vertical() { + let mut previous = None; + for epsilon in [1e-6, -1e-6] { + let region = + Region2 { outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(epsilon, 10.), Vec2::new(-5., 10.), Vec2::new(-5., 0.)]), holes: vec![] }; + let body = extrude(&Plane::XY, &[region], 0., 5.).unwrap().remove(0); + let plane = body + .face_sources() + .unwrap() + .iter() + .enumerate() + .find_map(|(index, source)| match source { + Some(FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.dist(Vec2::ZERO) < 1e-12 && b.y == 10. && b.x == epsilon => { + body.face_plane(index) + } + _ => None, + }) + .unwrap(); + assert!(plane.x.dot(Vec3::Y) > 0.999999); + assert_eq!(plane.y, Vec3::Z); + let attached = plane.to_world(Vec2::new(3., 2.)); + assert!(attached.dist(Vec3::new(epsilon * 0.3, 3., 2.)) < 1e-10); + if let Some(old) = previous { + assert!(attached.dist(old) < 1e-6); + } + previous = Some(attached); + assert_exact_planes(&body); + } +} + +#[test] +fn constructed_annulus_sides_follow_exact_source_edges() { + let ring = Region2 { outer: Loop2::circle(Vec2::default(), 10.), holes: vec![Loop2::circle(Vec2::default(), 9.).reversed()] }; + for (start, end) in [(0., 7.), (-7., 0.), (-7., 7.)] { + let bodies = extrude(&Plane::XY, std::slice::from_ref(&ring), start, end).unwrap(); + let body = &bodies[0]; + let sources = body.face_sources().unwrap(); + assert_eq!(sources.len(), body.face_count()); + let mesh = body.tessellate(0.001).unwrap(); + let mut inner = 0; + let mut outer = 0; + for (index, source) in sources.iter().enumerate() { + let Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, .. }, .. }) = source else { continue }; + if (*radius - 10.).abs() < 1e-12 { + outer += 1; + } else { + assert!((*radius - 9.).abs() < 1e-12); + inner += 1; + } + let mut vertices = 0; + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if *face as usize != index { + continue; + } + for point in mesh.tri(triangle).unwrap() { + assert!((point.x.hypot(point.y) - radius).abs() < 1e-8, "face {index}, source {radius}, {point:?}"); + vertices += 1; + } + } + assert!(vertices > 0); + } + assert_eq!((outer, inner), (2, 2), "full circles split into two actual edges each"); + assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { .. }))).count(), 2); + } +} + +#[test] +fn constructed_side_identity_survives_affine_placement_without_recognition() { + let region = Region2 { outer: Loop2::circle(Vec2::default(), 10.), holes: vec![] }; + let bodies = extrude(&Plane::named("XY").unwrap(), &[region], 0., 7.).unwrap(); + let source = &bodies[0]; + let placed = transform_matrix(source, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [25., 30., 40., 1.]]).unwrap(); + for (original, placed) in source.face_sources().unwrap().iter().zip(placed.face_sources().unwrap()) { + match (original, placed) { + (Some(FaceSource::ExtrudeSide { segment: a, .. }), Some(FaceSource::ExtrudeSide { segment: b, .. })) => assert_eq!(a, b), + (Some(FaceSource::ExtrudeCap { high: a, region: ar, .. }), Some(FaceSource::ExtrudeCap { high: b, region: br, .. })) => { + assert_eq!(a, b); + assert_eq!(ar, br) + } + _ => panic!("source role changed"), + } + } + assert!((placed.size() - source.size()).abs() < 1e-8); + assert!(placed.faces(0.001).unwrap().iter().all(|face| face.centroid.dist(Vec3::new(25., 30., 43.5)) < 11.)); +} + +#[test] +fn constructed_large_arc_split_and_reversal_preserve_one_source_segment() { + let arc = Seg2::Arc { center: Vec2::default(), radius: 10., start: 0., sweep: std::f64::consts::PI * 5. / 3. }; + let outline = Loop2 { segs: vec![arc, Seg2::Line { a: arc.end(), b: arc.start() }] }; + for outline in [outline.clone(), outline.reversed()] { + let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0., 7.).unwrap().remove(0); + let sources = body.face_sources().unwrap(); + let arcs: Vec<_> = sources + .iter() + .filter_map(|source| match source { + Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, sweep, .. }, .. }) => Some((radius, sweep)), + _ => None, + }) + .collect(); + assert_eq!(arcs.len(), 2, "large input arc explicitly creates two actual boundary edges"); + assert!(arcs.iter().all(|(radius, sweep)| (**radius - 10.).abs() < 1e-12 && (sweep.abs() - std::f64::consts::PI * 5. / 3.).abs() < 1e-12)); + assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { segment: Seg2::Line { .. }, .. }))).count(), 1); + let mesh = body.tessellate(0.001).unwrap(); + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if matches!(sources[*face as usize], Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { .. }, .. })) { + assert!(mesh.tri(triangle).unwrap().iter().all(|point| (point.x.hypot(point.y) - 10.).abs() < 1e-7)); + } + } + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index f0e8e878..5c6d28a9 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -20,6 +20,9 @@ mod build; mod coplanar; mod curveblend; mod delete_face; +mod emboss; +#[cfg(test)] +mod face_source_tests; mod freeform; pub mod fuzz; mod heal; @@ -51,9 +54,10 @@ mod topo; pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet}; pub use blend_corner::fillet_radii; pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points}; -pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint}; -pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; +pub use body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; +pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; pub use delete_face::delete_faces; +pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh}; pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; pub use iges_out::iges_export_bodies; @@ -64,6 +68,7 @@ pub use offset::with_same_surface; pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix}; pub use polybool::{faceted_boolean, planar_boolean}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; +pub use prism::{extrude_planar_regions, planar_regions_boolean}; pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew}; pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map}; pub use step::step_export; diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs index 4ec210a0..9c2a28c6 100644 --- a/crates/kernel/src/meshbody.rs +++ b/crates/kernel/src/meshbody.rs @@ -108,6 +108,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result { color: None, paint: None, splits: None, + face_sources: None, }) } @@ -199,6 +200,7 @@ impl Body { color: self.color, paint: self.paint.clone(), splits: self.splits.clone(), + face_sources: None, }) } } diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 045f48ba..5cc5904d 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use solvecraft_geom::Vec3; use truck_modeling as mt; -use crate::body::{Body, Solid, from_p3, p3, v3}; +use crate::body::{Body, Solid, from_p3}; use crate::{KernelError, Result, guard}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -114,6 +114,13 @@ fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solv const ALL: f64 = 0.997; let disjoint = b_in_a < NONE && a_in_b < NONE; match op { + // Sampling only selects this candidate. Validate every shell boundary and the + // complete material/void nesting before preserving a disconnected union. + BoolOp::Union if disjoint => { + let joined = join_pieces(vec![a.clone(), b.clone()]).ok().flatten()?; + joined.separated_lumps().ok()?; + Some(Some(joined)) + } BoolOp::Cut if disjoint => Some(Some(a.clone())), BoolOp::Cut if a_in_b > ALL => Some(None), BoolOp::Intersect if disjoint => Some(None), @@ -122,31 +129,34 @@ fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solv BoolOp::Union if b_in_a > ALL => Some(Some(a.clone())), BoolOp::Union if a_in_b > ALL => Some(Some(b.clone())), // The tool wholly inside, clear of the outside: a void. - BoolOp::Cut if b_in_a > ALL && clear_inside(ma, mb, a.size().max(b.size()) * 1e-6) => void(a, b).ok(), + BoolOp::Cut if b_in_a > ALL => { + let candidate = void(a, b).ok().flatten()?; + candidate.separated_lumps().ok()?; + Some(Some(candidate)) + } _ => None, } } -/// Does every vertex of `inner` lie inside `outer` and farther than `gap` from its surface? -fn clear_inside(outer: &solvecraft_geom::Mesh, inner: &solvecraft_geom::Mesh, gap: f64) -> bool { - if outer.triangles.len() * inner.positions.len() > 40_000_000 { - return false; +#[cfg(test)] +mod shortcut_tests { + use super::*; + + #[test] + fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() { + let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap(); + let outer = crate::sphere(Vec3::ZERO, 10.0).unwrap(); + let mesh = outer.tessellate(0.03).unwrap(); + for distance in [19.999, 20.001] { + let other = crate::sphere(direction * distance, 10.0).unwrap(); + let other_mesh = other.tessellate(0.03).unwrap(); + assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh).is_none()); + assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh).is_none()); + } + let protruding = crate::sphere(direction * 9.001, 1.0).unwrap(); + let protruding_mesh = protruding.tessellate(0.03).unwrap(); + assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh).is_none()); } - let tris: Vec<[Vec3; 3]> = outer.triangles.iter().filter_map(|t| outer.tri(t)).collect(); - inner.positions.iter().all(|p| { - outer.contains(*p) - && tris.iter().all(|[a, b, c]| { - // Distance to the triangle's plane bounds the distance to the triangle from below. - let n = (*b - *a).cross(*c - *a); - let Some(nn) = n.normalized() else { return true }; - let h = (*p - *a).dot(nn).abs(); - h > gap || { - let q = *p - nn * (*p - *a).dot(nn); - let inside = [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0); - !inside && [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| p.dist_to_segment(*u, *v) > gap) - } - }) - }) } /// `a` with the inside of `b` (wholly within it) taken out: a second, inward-facing shell. @@ -520,16 +530,95 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result { m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2], m[3][3], ); - Body::new(mt::builder::transformed(&body.deep_copy(), mat)) + // An affine placement retains face order and appearance metadata. + let sources = body.transformed_sources(m)?; + Body::new(placed_topology(&body.solid, mat)?) + .map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources)) }) } +// A STEP periodic face may traverse a seam edge twice. Generic topology builders +// reject that wire while cloning, even though placement preserves its topology. +pub(crate) fn placed_topology(source: &Solid, matrix: mt::Matrix4) -> Result { + use mt::{Transform, Transformed}; + use std::collections::HashMap; + let mut vertices = HashMap::new(); + let mut edges = HashMap::new(); + let mut shells = Vec::new(); + let mut face_count = 0usize; + let mut edge_visits = 0usize; + for shell in source.boundaries() { + let mut faces = Vec::new(); + for face in shell { + face_count = face_count.saturating_add(1); + if face_count > 500_000 { + return Err(KernelError::Invalid("placement exceeds face work limit".into())); + } + let mut wires = Vec::new(); + for wire in face.absolute_boundaries() { + let mut mapped = Vec::new(); + for edge in wire { + edge_visits = edge_visits.saturating_add(1); + if edge_visits > 4_000_000 { + return Err(KernelError::Invalid("placement exceeds edge work limit".into())); + } + if let std::collections::hash_map::Entry::Vacant(entry) = edges.entry(edge.id()) { + for vertex in [edge.absolute_front(), edge.absolute_back()] { + if let std::collections::hash_map::Entry::Vacant(entry) = vertices.entry(vertex.id()) { + let point = matrix.transform_point(vertex.point()); + if !from_p3(point).is_finite() { + return Err(KernelError::Invalid("placement produced nonfinite geometry".into())); + } + entry.insert(mt::Vertex::new(point)); + } + } + let front = + vertices.get(&edge.absolute_front().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?; + let back = vertices.get(&edge.absolute_back().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?; + entry.insert(mt::Edge::new_unchecked(front, back, edge.curve().transformed(matrix))); + } + let copied = edges.get(&edge.id()).ok_or_else(|| KernelError::Failed("placement edge missing".into()))?; + mapped.push(if edge.orientation() { copied.clone() } else { copied.inverse() }); + } + wires.push(mt::Wire::from(mapped)); + } + // Wire connectivity, seam sharing and orientation are copied exactly; + // no new topology is introduced by this geometry-only operation. + let mut copied = mt::Face::new_unchecked(wires, face.surface().transformed(matrix)); + if !face.orientation() { + copied.invert(); + } + faces.push(copied); + } + shells.push(mt::Shell::from(faces)); + } + Ok(Solid::new_unchecked(shells)) +} + +#[cfg(test)] +#[test] +fn affine_placement_keeps_body_and_face_paint_attached() { + let paint = crate::Paint { opacity: 0.4, faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.2, 0.1], opacity: 0.3 }] }; + let body = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap().with_color(Some([0.2, 0.6, 0.8])).with_paint(Some(paint.clone())); + let source_faces = body.faces(0.01).unwrap(); + let transform = [[0.0, 2.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [30.0, 40.0, 50.0, 1.0]]; + let placed = transform_matrix(&body, transform).unwrap(); + assert_eq!(placed.color(), body.color()); + assert_eq!(placed.paint(), Some(&paint)); + let faces = placed.faces(0.01).unwrap(); + for source in source_faces { + let target = faces.iter().find(|f| f.index == source.index).unwrap(); + let p = source.centroid; + assert!(target.centroid.dist(Vec3::new(30.0 - p.y, 40.0 + 2.0 * p.x, 50.0 + 0.5 * p.z)) < 1e-6); + } +} + /// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate. pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result { if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) { return Err(KernelError::Invalid("non-finite transform".into())); } - if body.is_mesh() { + { // Same motion as a matrix: rotate about the line through `origin`, then translate. let ax = if angle.abs() > 1e-12 { axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))? } else { Vec3::Z }; let (c, s) = (angle.cos(), angle.sin()); @@ -546,19 +635,8 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: ); let tr = origin - ro + translate; let m = [[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]]; - return body.mesh_transformed(&m).ok_or_else(|| KernelError::Failed("mesh transform".into())); + transform_matrix(body, m) } - guard("transform", || { - let mut s = body.deep_copy(); - if angle.abs() > 1e-12 { - let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))?; - s = mt::builder::rotated(&s, p3(origin), v3(ax), mt::Rad(angle)); - } - if translate.len() > 0.0 { - s = mt::builder::translated(&s, v3(translate)); - } - Body::new(s) - }) } /// Split a body with a plane: the parts on the positive and the negative side of the plane @@ -594,3 +672,7 @@ pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?; boolean(body, &half, BoolOp::Intersect) } + +#[cfg(test)] +#[path = "placement_tests.rs"] +mod placement_tests; diff --git a/crates/kernel/src/placement_tests.rs b/crates/kernel/src/placement_tests.rs new file mode 100644 index 00000000..2a038bf0 --- /dev/null +++ b/crates/kernel/src/placement_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use crate::body::{p3, v3}; +use mt::builder; + +fn assert_topology(source: &Body, placed: &Body, matrix: [[f64; 4]; 4]) { + use std::collections::HashMap; + let mut vertices = HashMap::new(); + let mut edges = HashMap::new(); + for (a, b) in source.solid.face_iter().zip(placed.solid.face_iter()) { + assert_eq!(a.orientation(), b.orientation()); + let aw = a.absolute_boundaries(); + let bw = b.absolute_boundaries(); + assert_eq!(aw.len(), bw.len()); + for (a, b) in aw.iter().zip(bw.iter()) { + assert_eq!(a.len(), b.len()); + for (a, b) in a.edge_iter().zip(b.edge_iter()) { + assert_eq!(a.orientation(), b.orientation()); + assert_ne!(a.id(), b.id()); + assert_eq!(*edges.entry(a.id()).or_insert(b.id()), b.id()); + for (a, b) in [(a.absolute_front(), b.absolute_front()), (a.absolute_back(), b.absolute_back())] { + assert_ne!(a.id(), b.id()); + assert_eq!(*vertices.entry(a.id()).or_insert(b.id()), b.id()); + let p = from_p3(a.point()); + let expected = Vec3::new( + matrix[0][0] * p.x + matrix[1][0] * p.y + matrix[2][0] * p.z + matrix[3][0], + matrix[0][1] * p.x + matrix[1][1] * p.y + matrix[2][1] * p.z + matrix[3][1], + matrix[0][2] * p.x + matrix[1][2] * p.y + matrix[2][2] * p.z + matrix[3][2], + ); + assert!(from_p3(b.point()).dist(expected) < 1e-9); + } + } + } + } + assert_eq!(edges.len(), source.solid.edge_iter().map(|e| e.id()).collect::>().len()); +} + +fn periodic_cylinder() -> Body { + let b0 = builder::vertex(p3(Vec3::new(10., 0., 0.))); + let b1 = builder::vertex(p3(Vec3::new(-10., 0., 0.))); + let t0 = builder::vertex(p3(Vec3::new(10., 0., 20.))); + let t1 = builder::vertex(p3(Vec3::new(-10., 0., 20.))); + let bottom: mt::Wire = + vec![builder::circle_arc(&b0, &b1, p3(Vec3::new(0., 10., 0.))), builder::circle_arc(&b1, &b0, p3(Vec3::new(0., -10., 0.)))].into(); + let top: mt::Wire = + vec![builder::circle_arc(&t0, &t1, p3(Vec3::new(0., 10., 20.))), builder::circle_arc(&t1, &t0, p3(Vec3::new(0., -10., 20.)))].into(); + let seam = builder::line(&b0, &t0); + let mut boundary: Vec<_> = bottom.edge_iter().cloned().collect(); + boundary.push(seam.clone()); + boundary.extend(top.inverse().edge_iter().cloned()); + boundary.push(seam.inverse()); + let wall = mt::Face::new_unchecked( + vec![boundary.into()], + mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution( + seam.inverse().oriented_curve(), + p3(Vec3::ZERO), + v3(Vec3::Z), + ))), + ); + let lower = builder::try_attach_plane(&[bottom.inverse()]).unwrap(); + let upper = builder::try_attach_plane(&[top]).unwrap(); + Body::new(Solid::new_unchecked(vec![vec![wall, lower, upper].into()])).unwrap() +} + +#[test] +fn periodic_step_seam_survives_identity_rotation_and_translation() { + let source = periodic_cylinder(); + let bytes = crate::step_export(&[&source], "placement regression").unwrap(); + let imported = crate::step_import(&bytes).unwrap(); + assert!(imported.warnings.is_empty(), "{:?}", imported.warnings); + let body = &imported.bodies.first().unwrap().body; + assert!(body.solid.face_iter().any(|f| f.absolute_boundaries().iter().any(|w| !w.is_simple()))); + let source_vertices: Vec<_> = body.solid.vertex_iter().map(|v| from_p3(v.point())).collect(); + let source_faces = body.solid.face_iter().count(); + let unchanged = crate::step_export(&[body], "source snapshot").unwrap(); + let before = body.tessellate_with(1e-3, true).unwrap().measure().volume; + assert!((before - 2000. * std::f64::consts::PI).abs() < 1.); + for matrix in [ + [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]], + [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [10., 10., 0., 1.]], + [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [10., 10., 30., 1.]], + ] { + let moved = transform_matrix(body, matrix).unwrap(); + assert_topology(body, &moved, matrix); + assert_eq!(moved.solid.face_iter().count(), source_faces); + assert_eq!(moved.solid.vertex_iter().count(), source_vertices.len()); + let after = moved.tessellate_with(1e-3, true).unwrap().measure().volume; + assert!((after - before).abs() < 1e-5, "{after} vs {before}"); + let roundtrip = crate::step_import(&crate::step_export(&[&moved], "placement regression").unwrap()).unwrap(); + assert!(roundtrip.warnings.is_empty(), "{:?}", roundtrip.warnings); + assert!((roundtrip.bodies.first().unwrap().body.tessellate_with(1e-3, true).unwrap().measure().volume - before).abs() < 1e-4); + } + let rigid = transform(body, Vec3::new(10., 10., 30.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2).unwrap(); + assert_topology(body, &rigid, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [10., 10., 30., 1.]]); + assert_eq!(crate::step_export(&[body], "source snapshot").unwrap(), unchanged); +} diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 2c29606f..a6c19847 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -10,7 +10,108 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; use truck_modeling as mt; use crate::body::{Body, from_p3}; -use crate::{BoolOp, Result}; +use crate::{BoolOp, KernelError, Result}; + +const MAX_PLANAR_SEGMENTS: usize = 8192; +const MAX_PLANAR_EVENTS: usize = 131_072; +const MAX_PLANAR_PAIRS: usize = 16_000_000; + +/// Boolean of planar regions bounded by straight lines and circular arcs. Boundaries stay +/// analytic; disjoint results and retained islands are returned as separate regions. +pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, tolerance: f64) -> Result> { + crate::guard("planar region boolean", || { + if !tolerance.is_finite() || !(1e-9..=1.0).contains(&tolerance) { + return Err(KernelError::Invalid("planar boolean tolerance must be finite and between 1e-9 and 1 mm".into())); + } + let loops = || a.iter().chain(b).flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)); + let mut edges = 0usize; + let mut samples = 0usize; + for lp in loops() { + edges = edges.saturating_add(lp.segs.len()); + if edges > MAX_PLANAR_SEGMENTS || lp.segs.is_empty() || !lp.is_closed(tolerance * 10.0) { + return Err(KernelError::Invalid("planar boolean needs closed loops within its geometry budget".into())); + } + for s in &lp.segs { + let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9; + let valid = match *s { + Seg2::Line { a, b } => finite(a) && finite(b) && a.dist(b) > 1e-12, + Seg2::Arc { center, radius, start, sweep } => { + finite(center) + && radius.is_finite() + && radius > 0.0 + && radius < 1e9 + && start.is_finite() + && sweep.is_finite() + && sweep.abs() > 1e-12 + && sweep.abs() <= std::f64::consts::TAU + 1e-9 + } + _ => false, + }; + if !valid { + return Err(KernelError::Invalid("planar boolean supports finite nondegenerate lines and circular arcs".into())); + } + samples = samples.saturating_add(s.polyline(1e-3).len()); + if samples > 65_536 { + return Err(KernelError::Invalid("planar boolean sampled geometry exceeds its budget".into())); + } + } + } + boolean2d(a, b, op, tolerance) + .ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into())) + }) +} + +/// Extrude normalized planar material as one logical compound, retaining every disjoint +/// region and its holes. Planar arrangement certifies separation before shells are joined. +pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result { + crate::guard("planar material extrusion", || { + // Validate aggregate inputs first; an arrangement compares opposite operands, + // so regions in the same operand must be normalized through balanced unions. + planar_regions_boolean(regions, &[], BoolOp::Union, 1e-7)?; + let mut groups: Vec> = regions.iter().cloned().map(|r| vec![r]).collect(); + let mut remaining_pairs = 32_000_000usize; + while groups.len() > 1 { + let mut next = Vec::new(); + let mut iter = groups.into_iter(); + while let Some(a) = iter.next() { + if let Some(b) = iter.next() { + let count = |rs: &[Region2]| { + rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, l| n.saturating_add(l.segs.len())) + }; + remaining_pairs = remaining_pairs + .checked_sub(count(&a).saturating_mul(count(&b))) + .ok_or_else(|| KernelError::Invalid("planar extrusion arrangement exceeds its work budget".into()))?; + next.push(planar_regions_boolean(&a, &b, BoolOp::Union, 1e-7)?); + } else { + next.push(a); + } + } + groups = next; + } + let normalized = groups.pop().unwrap_or_default(); + if normalized.is_empty() { + return Err(KernelError::Invalid("empty planar material extrusion".into())); + } + for (i, a) in normalized.iter().enumerate() { + for b in normalized.iter().skip(i + 1) { + for s in std::iter::once(&a.outer).chain(&a.holes).flat_map(|l| &l.segs) { + for t in std::iter::once(&b.outer).chain(&b.holes).flat_map(|l| &l.segs) { + remaining_pairs = remaining_pairs + .checked_sub(1) + .ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?; + let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7) + || [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7); + if endpoints_touch || !crossings(s, t, 1e-7).is_empty() { + return Err(KernelError::Invalid("planar material pieces touch at a boundary".into())); + } + } + } + } + } + let pieces = crate::extrude(plane, &normalized, start, end)?; + crate::ops::join_pieces(pieces)?.ok_or_else(|| KernelError::Failed("empty planar material extrusion".into())) + }) +} /// A prism: the plane of its bottom cap (normal along the walls), its height and section. struct Prism { @@ -191,9 +292,83 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> { out } -/// Exact point-in-loop by winding (arcs as fine polylines are enough away from the boundary). +/// Ray parity against analytic lines/arcs. Fragment classification must not depend on a +/// display chord tolerance: a tiny tool can sit between a circular boundary and its chords. +fn inside_loop(lp: &Loop2, p: Vec2) -> bool { + let mut odd = false; + for seg in &lp.segs { + match *seg { + Seg2::Line { a, b } => { + if (a.y > p.y) != (b.y > p.y) { + let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y); + if x > p.x { + odd = !odd; + } + } + } + Seg2::Arc { center, radius, start, sweep } => { + let y = (p.y - center.y) / radius; + if !(-1.0..=1.0).contains(&y) { + continue; + } + let a = y.asin(); + let root = (1.0 - y * y).max(0.0).sqrt(); + let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-12; + for (i, (angle, x)) in [(a, center.x + radius * root), (std::f64::consts::PI - a, center.x - radius * root)].into_iter().enumerate() { + // A tangent has one geometric root. Interior tangencies contribute zero + // parity; a minimum at an endpoint follows the same half-open rule as lines. + if root == 0.0 && i == 1 { + continue; + } + if x <= p.x { + continue; + } + let mut delta = if sweep > 0.0 { angle - start } else { start - angle }; + delta = delta.rem_euclid(std::f64::consts::TAU); + if (delta - std::f64::consts::TAU).abs() < 1e-12 { + delta = 0.0; + } + if delta > sweep.abs() + 1e-12 { + continue; + } + let dy = angle.cos() * sweep; + let tangent = dy.abs() < sweep.abs() * 1e-12; + let at_start = delta < 1e-12; + let at_end = (delta - sweep.abs()).abs() < 1e-12; + let crosses = if full { + !tangent + } else if at_start { + dy > 0.0 && !tangent || tangent && y < 0.0 + } else if at_end { + dy < 0.0 && !tangent || tangent && y < 0.0 + } else { + !tangent + }; + if crosses { + odd = !odd; + } + } + } + _ => { + // Private prism callers only supply lines/arcs. Keep a controlled fallback + // for future internal callers; the public helper rejects other segment kinds. + let points = seg.polyline(1e-7); + for pair in points.windows(2) { + if let [a, b] = pair + && (a.y > p.y) != (b.y > p.y) + && a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) > p.x + { + odd = !odd; + } + } + } + } + } + odd +} + fn inside(regions: &[Region2], p: Vec2) -> bool { - regions.iter().any(|r| r.contains(p)) + regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p))) } fn dist_to(regions: &[Region2], p: Vec2) -> f64 { @@ -233,19 +408,30 @@ fn oriented(regions: &[Region2]) -> Vec { /// 2D boolean of regions bounded by lines and arcs. fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option> { + let count = + |rs: &[Region2]| rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, lp| n.saturating_add(lp.segs.len())); + let (na, nb) = (count(a), count(b)); + if na.saturating_add(nb) > MAX_PLANAR_SEGMENTS || na.saturating_mul(nb) > MAX_PLANAR_PAIRS { + return None; + } let (a, b) = (oriented(a), oriented(b)); let segs = |rs: &[Region2]| -> Vec { rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).flat_map(|l| l.segs.clone()).collect() }; let (sa, sb) = (segs(&a), segs(&b)); - if sa.len() * sb.len() > 4_000_000 || sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) { + if sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) { return None; } // Split every segment where it crosses the other side's boundary. let mut cut_a: Vec> = vec![Vec::new(); sa.len()]; let mut cut_b: Vec> = vec![Vec::new(); sb.len()]; + let mut events = 0usize; for (i, s) in sa.iter().enumerate() { for (j, t) in sb.iter().enumerate() { for (u, v) in crossings(s, t, tol) { + events += 2; + if events > MAX_PLANAR_EVENTS { + return None; + } cut_a.get_mut(i)?.push(u); cut_b.get_mut(j)?.push(v); } @@ -257,6 +443,10 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option MAX_PLANAR_EVENTS { + return None; + } cut_a.get_mut(i)?.push(u); } } @@ -267,6 +457,10 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option MAX_PLANAR_EVENTS { + return None; + } cut_b.get_mut(j)?.push(v); } } @@ -295,6 +489,9 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option = Vec::new(); // A's pieces. for (i, s) in sa.iter().enumerate() { + if cut_a.get(i)?.len() > MAX_PLANAR_SEGMENTS { + return None; + } for p in pieces(s, cut_a.get_mut(i)?) { let m = p.point_at(0.5); let on = dist_to(&b, m) < tol; @@ -312,18 +509,27 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option= MAX_PLANAR_SEGMENTS { + return None; + } kept.push(p); } } } // B's pieces (coincident ones were decided with A's). for (j, t) in sb.iter().enumerate() { + if cut_b.get(j)?.len() > MAX_PLANAR_SEGMENTS { + return None; + } for p in pieces(t, cut_b.get_mut(j)?) { let m = p.point_at(0.5); if dist_to(&a, m) < tol { continue; } let ins = inside(&a, m); + if kept.len() >= MAX_PLANAR_SEGMENTS { + return None; + } match op { BoolOp::Union if !ins => kept.push(p), BoolOp::Intersect if ins => kept.push(p), @@ -351,7 +557,9 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option 1 { + let full_circle = + segs.first().is_some_and(|s| matches!(s, Seg2::Arc { sweep, .. } if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9)); + if end.dist(first) < tol * 10.0 && (segs.len() > 1 || full_circle) { break; } let next = (0..kept.len()) @@ -379,7 +587,7 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option = outers.into_iter().map(|o| Region2 { outer: o, holes: Vec::new() }).collect(); for h in holes { let p = h.segs.first()?.point_at(0.5); - let r = regions.iter_mut().find(|r| r.outer.contains(p) && r.outer.signed_area().abs() > h.signed_area().abs())?; + let r = regions.iter_mut().find(|r| inside_loop(&r.outer, p) && r.outer.signed_area().abs() > h.signed_area().abs())?; r.holes.push(h); } Some(regions) @@ -654,9 +862,13 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option> { } fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { - // This path repairs joins with a common outer cap (a boss flush with a rim). - // Other extents and subtractive operations retain their existing B-rep path. - if op != BoolOp::Union { + // Common-cap joins and partial-depth cuts of aligned stepped prisms can + // retain their exact section boundaries instead of intersecting meshes. + // Other extents retain their existing B-rep path. + if !matches!(op, BoolOp::Union | BoolOp::Cut) { + return None; + } + if op == BoolOp::Cut && a.face_count().saturating_add(b.face_count()) > 2048 { return None; } fn section(ps: &[Prism], lo: f64, hi: f64, tol: f64) -> &[Region2] { @@ -674,28 +886,46 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> } } } + let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi))); let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| { let plane = Plane::from_normal(Vec3::ZERO, d)?; - Some((d, plane, stack_of(a, d, &plane, tol)?, stack_of(b, d, &plane, tol)?)) + let pa = stack_of(a, d, &plane, tol)?; + let pb = stack_of(b, d, &plane, tol)?; + let (ea, eb) = (ends(&pa), ends(&pb)); + if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol { + return None; + } + Some((d, plane, pa, pb)) })?; - let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi))); - let (ea, eb) = (ends(&pa), ends(&pb)); - if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol { - return None; - } let mut levels: Vec = pa.iter().chain(&pb).flat_map(|p| [p.lo, p.hi]).collect(); levels.sort_by(f64::total_cmp); levels.dedup_by(|x, y| (*x - *y).abs() < tol); let mut slabs = Vec::new(); + let mut work = 0usize; + let mut output_edges = 0usize; + let edges = |regions: &[Region2]| regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).sum::(); for w in levels.windows(2) { let (lo, hi) = (*w.first()?, *w.get(1)?); let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol)); + if op == BoolOp::Cut { + let count = edges(x).saturating_add(edges(y)); + work = work.saturating_add(count.saturating_mul(count)); + if work > 32_000_000 { + return None; + } + } let s = match (x.is_empty(), y.is_empty(), op) { (true, _, BoolOp::Cut | BoolOp::Intersect) | (_, true, BoolOp::Intersect) => Vec::new(), (true, _, BoolOp::Union) => y.to_vec(), (_, true, _) => x.to_vec(), _ => boolean2d(x, y, op, tol)?, }; + if op == BoolOp::Cut { + output_edges = output_edges.saturating_add(edges(&s)); + if output_edges > 8192 { + return None; + } + } slabs.push(oriented(&s)); } if slabs.iter().all(Vec::is_empty) { @@ -707,3 +937,125 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { crate::guard("prism boolean", || Ok(simple_prism_boolean(a, b, op).or_else(|| stack_boolean(a, b, op)))).ok().flatten() } + +#[cfg(test)] +mod planar_tests { + use super::*; + fn rectangle(a: Vec2, b: Vec2) -> Region2 { + Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] } + } + #[test] + fn partial_depth_cuts_touching_a_through_hole_use_exact_stacked_sections() { + let plane = Plane::named("XY").unwrap(); + let mut plate = rectangle(Vec2::ZERO, Vec2::new(100., 60.)); + plate.holes.push(rectangle(Vec2::new(25., 20.), Vec2::new(30., 30.)).outer); + let stock = crate::extrude(&plane, &[plate], 0., 2.5).unwrap().pop().unwrap(); + let low = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(30., 30.))], 0., 1.5).unwrap().pop().unwrap(); + let high = crate::extrude(&plane, &[rectangle(Vec2::new(25., 20.), Vec2::new(35., 30.))], 1., 2.5).unwrap().pop().unwrap(); + for tools in [[&low, &high], [&high, &low]] { + let mut result = stock.clone(); + for tool in tools { + result = stack_boolean(&result, tool, BoolOp::Cut).expect("exact stack path").unwrap().unwrap(); + } + assert!(result.validity().is_empty()); + assert_eq!(result.shell_count(), 1); + assert!((crate::measure(&result).unwrap().volume - 14_725.).abs() < 1e-6); + let mesh = result.tessellate(1e-4).unwrap(); + for (point, material) in [ + (Vec3::new(22.5, 25., 0.5), false), + (Vec3::new(22.5, 25., 2.), true), + (Vec3::new(32.5, 25., 0.5), true), + (Vec3::new(32.5, 25., 2.), false), + (Vec3::new(27.5, 25., 1.25), false), + (Vec3::new(10., 10., 1.25), true), + ] { + assert_eq!(mesh.contains(point), material, "{point:?}"); + } + let imported = crate::step_import(&crate::step_export(&[&result], "layered cut").unwrap()).unwrap(); + assert!(imported.warnings.is_empty()); + assert!((crate::measure(&imported.bodies.first().unwrap().body).unwrap().volume - 14_725.).abs() < 1e-6); + let next = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(25., 30.))], 1.5, 2.5).unwrap().pop().unwrap(); + let opened = crate::boolean(&imported.bodies.first().unwrap().body, &next, BoolOp::Cut).unwrap().unwrap(); + assert!((crate::measure(&opened).unwrap().volume - 14_675.).abs() < 1e-6); + assert!(opened.validity().is_empty()); + } + } + #[test] + fn compound_planar_extrusion_normalizes_overlap_and_retains_islands() { + let plane = Plane::named("XY").unwrap(); + let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); + let b = rectangle(Vec2::new(5.0, 0.0), Vec2::new(15.0, 10.0)); + let body = extrude_planar_regions(&plane, &[a.clone(), b], 0.0, 2.0).unwrap(); + assert!((crate::measure(&body).unwrap().volume - 300.0).abs() < 1e-6); + let inner = Region2 { outer: Loop2::circle(Vec2::new(5.0, 5.0), 2.0), holes: vec![] }; + let body = extrude_planar_regions(&plane, &[a.clone(), inner.clone()], 0.0, 2.0).unwrap(); + assert!((crate::measure(&body).unwrap().volume - 200.0).abs() < 1e-6); + let annulus = Region2 { outer: a.outer, holes: vec![Loop2::circle(Vec2::new(5.0, 5.0), 3.0)] }; + let body = extrude_planar_regions(&plane, &[annulus, inner], 0.0, 2.0).unwrap(); + let expected = (100.0 - std::f64::consts::PI * 5.0) * 2.0; + assert!((crate::measure(&body).unwrap().volume - expected).abs() < 0.1); + assert_eq!(body.solid.boundaries().len(), 2); + let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); + let b = rectangle(Vec2::new(10.0, 10.0), Vec2::new(20.0, 20.0)); + assert!(extrude_planar_regions(&plane, &[a, b], 0.0, 2.0).is_err()); + let circles = [Vec2::ZERO, Vec2::new(10.0, 0.0)].map(|center| Region2 { outer: Loop2::circle(center, 5.0), holes: vec![] }); + assert!(extrude_planar_regions(&plane, &circles, 0.0, 2.0).is_err()); + } + + #[test] + fn analytic_circle_classification_respects_signed_sweeps_and_seams() { + for seam in [0.0, 0.13, std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2] { + for reversed in [false, true] { + let lp = Loop2::circle_from(Vec2::ZERO, 10.0, seam); + let lp = if reversed { lp.reversed() } else { lp }; + for p in [Vec2::ZERO, Vec2::new(9.9998, 0.0), Vec2::new(0.0, 9.9998), Vec2::new(0.0, -9.9998)] { + assert!(inside_loop(&lp, p), "{seam} {reversed} {p:?}"); + } + for p in [Vec2::new(11.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(0.0, -10.0)] { + assert!(!inside_loop(&lp, p), "{seam} {reversed} {p:?}"); + } + } + } + let half = Loop2 { + segs: vec![ + Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, -10.0) }, + ], + }; + for lp in [half.clone(), half.reversed()] { + assert!(inside_loop(&lp, Vec2::new(5.0, 0.0))); + assert!(!inside_loop(&lp, Vec2::new(-5.0, 0.0))); + assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0))); + } + } + #[test] + fn tiny_tool_between_circle_and_display_chord_is_not_lost() { + let circle = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] }; + let sampled = circle.outer.polyline(1e-3); + let direction = (sampled[0] + sampled[1]).normalized().unwrap(); + let at = direction * 9.9998; + let half = Vec2::new(0.000025, 0.000025); + let square = rectangle(at - half, at + half); + assert!(square.outer.segs.iter().all(|s| s.start().len() < 10.0)); + assert!(!circle.contains(at), "fixture must lie outside the display chord"); + let cut = planar_regions_boolean(std::slice::from_ref(&circle), std::slice::from_ref(&square), BoolOp::Cut, 1e-7).unwrap(); + assert_eq!(cut.len(), 1); + assert_eq!(cut[0].holes.len(), 1); + assert!((cut[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13); + let intersect = planar_regions_boolean(&[circle], &[square], BoolOp::Intersect, 1e-7).unwrap(); + assert_eq!(intersect.len(), 1); + assert!((intersect[0].area() - 2.5e-9).abs() < 1e-13); + } + #[test] + fn planar_region_api_rejects_hostile_values_and_budgets() { + let plate = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); + for tolerance in [f64::NAN, f64::INFINITY, 0.0, -1.0] { + assert!(planar_regions_boolean(std::slice::from_ref(&plate), &[], BoolOp::Union, tolerance).is_err()); + } + let mut bad = plate.clone(); + bad.outer.segs[0] = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }; + assert!(planar_regions_boolean(&[bad], &[], BoolOp::Union, 1e-7).is_err()); + let many = vec![plate; MAX_PLANAR_SEGMENTS / 4 + 1]; + assert!(planar_regions_boolean(&many, &[], BoolOp::Union, 1e-7).is_err()); + } +} diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs index cfcfe181..25c7a938 100644 --- a/crates/kernel/src/sew.rs +++ b/crates/kernel/src/sew.rs @@ -193,6 +193,72 @@ fn faces_connected(faces: &[mt::Face]) -> bool { seen.len() == faces.len() } +/// Keep disconnected closed boundaries as distinct shells, preserving orientation. +fn closed_components(faces: Vec) -> Result> { + const MAX_EDGE_USES: usize = 500_000; + const MAX_COMPONENTS: usize = 1024; + let fail = |s: &str| KernelError::Invalid(format!("sew: {s}")); + let mut by_edge: HashMap> = HashMap::new(); + let mut edge_uses = 0usize; + for (i, face) in faces.iter().enumerate() { + for edge in face.edge_iter() { + edge_uses = edge_uses.saturating_add(1); + if edge_uses > MAX_EDGE_USES { + return Err(fail("edge budget exceeded")); + } + let uses = by_edge.entry(edge.id()).or_default(); + if uses.len() >= 2 { + return Err(fail("nonmanifold edge contact")); + } + uses.push(i); + } + } + if by_edge.values().any(|uses| uses.len() != 2) { + return Err(fail("the faces do not close")); + } + let mut seen = vec![false; faces.len()]; + let mut vertices = HashMap::new(); + let mut components = Vec::new(); + for first in 0..faces.len() { + if seen.get(first).copied().unwrap_or(true) { + continue; + } + if components.len() >= MAX_COMPONENTS { + return Err(fail("component budget exceeded")); + } + let component = components.len(); + let mut todo = vec![first]; + if let Some(slot) = seen.get_mut(first) { + *slot = true; + } + let mut indices = Vec::new(); + while let Some(i) = todo.pop() { + let face = faces.get(i).ok_or_else(|| fail("missing component face"))?; + indices.push(i); + for vertex in face.vertex_iter() { + if vertices.insert(vertex.id(), component).is_some_and(|previous| previous != component) { + return Err(fail("bodies touch only at a vertex")); + } + } + for edge in face.edge_iter() { + let adjacent = by_edge.get(&edge.id()).ok_or_else(|| fail("missing component edge"))?; + for j in adjacent { + let slot = seen.get_mut(*j).ok_or_else(|| fail("missing adjacent face"))?; + if !*slot { + *slot = true; + todo.push(*j); + } + } + } + } + indices.sort_unstable(); + let component_faces = + indices.into_iter().map(|i| faces.get(i).cloned().ok_or_else(|| fail("missing component face"))).collect::>>()?; + components.push(component_faces.into()); + } + Ok(components) +} + /// A contact's edge sheets can terminate in separate vertex fans. Give each fan /// its own vertex at the same coordinates so the boundary remains a manifold. fn split_vertex_fans(faces: Vec) -> Result> { @@ -285,8 +351,13 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result { if faces.len() < 4 || faces.len() > 200_000 { return Err(KernelError::Invalid("a solid needs 4…200000 faces".into())); } + let mut edge_uses = 0usize; for f in faces { for l in &f.loops { + edge_uses = edge_uses.saturating_add(l.len().max(1)); + if edge_uses > 500_000 { + return Err(KernelError::Invalid("sew: edge budget exceeded".into())); + } for e in l { let (a, b) = e.ends(); if !a.is_finite() || !b.is_finite() || a.dist(b) < 1e-9 { @@ -331,8 +402,8 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result { if !sheets.is_empty() { out = split_vertex_fans(out)?; } - let shell: mt::Shell = out.into(); - let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?; + let shells = closed_components(out)?; + let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?; Body::new(solid) }) } @@ -363,6 +434,65 @@ mod tests { } } + fn box_faces(origin: Vec3, size: Vec3) -> Vec { + let p = |x: f64, y: f64, z: f64| origin + Vec3::new(x * size.x, y * size.y, z * size.z); + let (a, b, c, d) = (p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.)); + let (e, f, g, h) = (p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.)); + vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)] + } + + #[test] + fn disconnected_closed_faces_keep_every_material_component() { + let mut faces = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)); + faces.extend(box_faces(Vec3::X * 10.0, Vec3::new(2.0, 3.0, 4.0))); + for reverse in [false, true] { + if reverse { + faces.reverse(); + } + let body = sew(&faces).unwrap(); + assert_eq!(body.shell_count(), 2); + assert!(body.validity().is_empty()); + assert!((crate::measure(&body).unwrap().volume - 48.0).abs() < 1e-9); + let lumps = body.lumps().unwrap(); + assert_eq!(lumps.len(), 2); + assert!(lumps.iter().all(|b| (crate::measure(b).unwrap().volume - 24.0).abs() < 1e-9)); + let mesh = body.tessellate(0.01).unwrap(); + assert!(mesh.contains(Vec3::new(1.0, 1.0, 1.0))); + assert!(mesh.contains(Vec3::new(11.0, 1.0, 1.0))); + assert!(!mesh.contains(Vec3::new(5.0, 1.0, 1.0))); + let imported = crate::step_import(&crate::step_export(&[&body], "sewn pieces").unwrap()).unwrap(); + assert!(imported.warnings.is_empty()); + let volume: f64 = imported.bodies.iter().map(|b| crate::measure(&b.body).unwrap().volume).sum(); + assert!((volume - 48.0).abs() < 1e-6); + } + } + + #[test] + fn sewing_preserves_inward_cavity_orientation_and_refuses_vertex_contact() { + let outer = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)); + let mut inner = box_faces(Vec3::new(0.5, 0.75, 1.0), Vec3::new(1.0, 1.5, 2.0)); + for face in &mut inner { + for lp in &mut face.loops { + lp.reverse(); + for edge in lp { + if let EdgeSpec::Line { a, b } = edge { + std::mem::swap(a, b); + } + } + } + } + let cavity = sew(&outer.iter().chain(&inner).cloned().collect::>()).unwrap(); + assert_eq!(cavity.shell_count(), 2); + assert_eq!(cavity.lumps().unwrap().len(), 1); + assert!((crate::measure(&cavity).unwrap().volume - 21.0).abs() < 1e-9); + assert!(!cavity.tessellate(0.01).unwrap().contains(Vec3::new(1.0, 1.5, 2.0))); + let touching = box_faces(Vec3::new(2.0, 3.0, 4.0), Vec3::new(2.0, 3.0, 4.0)); + assert!(sew(&outer.iter().chain(&touching).cloned().collect::>()).unwrap_err().to_string().contains("vertex")); + let mut hostile = outer.clone(); + hostile[0].loops = vec![Vec::new(); 500_001]; + assert!(sew(&hostile).unwrap_err().to_string().contains("budget")); + } + #[test] fn a_sewn_box() { let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z); diff --git a/crates/kernel/src/shell_groups.rs b/crates/kernel/src/shell_groups.rs new file mode 100644 index 00000000..a76ea0a4 --- /dev/null +++ b/crates/kernel/src/shell_groups.rs @@ -0,0 +1,222 @@ +//! Group disconnected material regions without moving a cavity to another enclosing shell. +//! A valid shell hierarchy alternates material and void at every nesting level. + +use solvecraft_geom::{Aabb3, Mesh, Vec3}; + +use crate::{KernelError, Result}; + +pub(super) const MAX_SHELLS: usize = 1024; +pub(super) const MAX_POINTS: usize = 100_000; +pub(super) const MAX_TRIANGLES: usize = 100_000; +const MAX_PAIR_WORK: usize = 40_000_000; +const MAX_PROBES: usize = 2_000_000; +const MAX_CONTAINMENT_WORK: usize = 40_000_000; + +fn failed(detail: &str) -> KernelError { + KernelError::Failed(format!("lumps: {detail}")) +} + +fn box_of(points: &[Vec3]) -> Aabb3 { + let mut bounds = Aabb3::EMPTY; + for p in points { + bounds.add(*p); + } + bounds +} + +fn boxes_separate(a: Aabb3, b: Aabb3, gap: f64) -> bool { + a.max.x < b.min.x - gap + || b.max.x < a.min.x - gap + || a.max.y < b.min.y - gap + || b.max.y < a.min.y - gap + || a.max.z < b.min.z - gap + || b.max.z < a.min.z - gap +} + +fn unit(v: Vec3) -> Option { + let scale = v.x.abs().max(v.y.abs()).max(v.z.abs()); + if !scale.is_finite() || scale == 0.0 { + return None; + } + (v / scale).normalized() +} + +/// Separating axes include the in-plane edge normals for coplanar triangles. +fn triangles_meet(a: &[Vec3; 3], b: &[Vec3; 3], gap: f64) -> bool { + let [a0, a1, a2] = *a; + let [b0, b1, b2] = *b; + let ea = [a1 - a0, a2 - a1, a0 - a2]; + let eb = [b1 - b0, b2 - b1, b0 - b2]; + let na = (a1 - a0).cross(a2 - a0); + let nb = (b1 - b0).cross(b2 - b0); + let separated = |axis: Vec3| { + let Some(n) = unit(axis) else { return false }; + // Translate projections first so a far-away body does not lose its clearance. + let range = |points: &[Vec3; 3]| { + points.iter().map(|p| (*p - a0).dot(n)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x), hi.max(x))) + }; + let ((alo, ahi), (blo, bhi)) = (range(a), range(b)); + ahi < blo - gap || bhi < alo - gap + }; + if separated(na) || separated(nb) { + return false; + } + for e in ea { + if separated(na.cross(e)) { + return false; + } + for f in eb { + if separated(e.cross(f)) { + return false; + } + } + } + for e in eb { + if separated(nb.cross(e)) { + return false; + } + } + true +} + +pub(super) fn groups(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result>> { + if meshes.is_empty() || meshes.len() != volumes.len() || meshes.len() > MAX_SHELLS { + return Err(failed("shell count exceeds the grouping limit or is invalid")); + } + if !gap.is_finite() || gap <= 0.0 { + return Err(failed("invalid shell clearance")); + } + let mut points = 0usize; + let mut triangle_count = 0usize; + let mut triangles = Vec::with_capacity(meshes.len()); + let mut triangle_bounds = Vec::with_capacity(meshes.len()); + let mut bounds = Vec::with_capacity(meshes.len()); + for (mesh, volume) in meshes.iter().zip(volumes) { + points = points.checked_add(mesh.positions.len()).ok_or_else(|| failed("point count exceeds the grouping limit"))?; + triangle_count = triangle_count.checked_add(mesh.triangles.len()).ok_or_else(|| failed("triangle count exceeds the grouping limit"))?; + if points > MAX_POINTS || triangle_count > MAX_TRIANGLES { + return Err(failed("mesh exceeds the shell grouping limit")); + } + if !volume.is_finite() + || *volume == 0.0 + || mesh.positions.is_empty() + || mesh.triangles.is_empty() + || mesh.positions.iter().any(|p| !p.is_finite()) + { + return Err(failed("cannot determine a closed shell's orientation")); + } + let tris: Vec<_> = mesh.triangles.iter().map(|t| mesh.tri(t).ok_or_else(|| failed("invalid shell triangle"))).collect::>()?; + if tris.iter().any(|[a, b, c]| unit((*b - *a).cross(*c - *a)).is_none()) { + return Err(failed("degenerate shell triangle")); + } + triangle_bounds.push(tris.iter().map(|t| box_of(t)).collect::>()); + triangles.push(tris); + bounds.push(mesh.bounds()); + } + let mut inside = vec![vec![false; meshes.len()]; meshes.len()]; + let mut pair_work = 0usize; + let mut probes = 0usize; + let mut containment_work = 0usize; + for (i, mesh) in meshes.iter().enumerate() { + for j in i + 1..meshes.len() { + let (Some(a_bounds), Some(b_bounds)) = (bounds.get(i), bounds.get(j)) else { return Err(failed("missing shell bounds")) }; + if boxes_separate(*a_bounds, *b_bounds, gap) { + continue; + } + let (Some(a), Some(b)) = (triangles.get(i), triangles.get(j)) else { return Err(failed("missing shell triangles")) }; + let work = a.len().checked_mul(b.len()).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?; + pair_work = pair_work.checked_add(work).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?; + if pair_work > MAX_PAIR_WORK { + return Err(failed("shell boundary work exceeds the grouping limit")); + } + for (ai, ta) in a.iter().enumerate() { + for (bi, tb) in b.iter().enumerate() { + let (Some(ba), Some(bb)) = (triangle_bounds.get(i).and_then(|x| x.get(ai)), triangle_bounds.get(j).and_then(|x| x.get(bi))) + else { + return Err(failed("missing triangle bounds")); + }; + if !boxes_separate(*ba, *bb, gap) && triangles_meet(ta, tb, gap) { + return Err(failed("shell boundaries intersect or touch; cavity ownership is ambiguous")); + } + } + } + for (inner, outer) in [(i, j), (j, i)] { + let ib = bounds.get(inner).ok_or_else(|| failed("missing shell bounds"))?; + let ob = bounds.get(outer).ok_or_else(|| failed("missing shell bounds"))?; + // With boundary intersections already excluded, a connected shell + // extending beyond another's bounds cannot be wholly contained. + if ib.min.x < ob.min.x - gap + || ib.min.y < ob.min.y - gap + || ib.min.z < ob.min.z - gap + || ib.max.x > ob.max.x + gap + || ib.max.y > ob.max.y + gap + || ib.max.z > ob.max.z + gap + { + continue; + } + let inner_mesh = if inner == i { mesh } else { meshes.get(inner).ok_or_else(|| failed("missing shell"))? }; + probes = probes.checked_add(inner_mesh.positions.len()).ok_or_else(|| failed("shell probes exceed the grouping limit"))?; + if probes > MAX_PROBES { + return Err(failed("shell probes exceed the grouping limit")); + } + let outer_mesh = meshes.get(outer).ok_or_else(|| failed("missing shell"))?; + let probe_count = inner_mesh.positions.len(); + let work = probe_count + .checked_mul(outer_mesh.triangles.len()) + .and_then(|n| n.checked_mul(3)) + .ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?; + containment_work = containment_work.checked_add(work).ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?; + if containment_work > MAX_CONTAINMENT_WORK { + return Err(failed("shell containment work exceeds the grouping limit")); + } + let mut contained = None; + for p in inner_mesh.positions.iter().copied() { + let hit = outer_mesh.contains(p); + if contained.is_some_and(|previous| previous != hit) { + return Err(failed("shell containment is inconsistent; cavity ownership is ambiguous")); + } + contained = Some(hit); + } + if let Some(slot) = inside.get_mut(inner).and_then(|row| row.get_mut(outer)) { + *slot = contained == Some(true); + } + } + } + } + let mut parent = Vec::with_capacity(meshes.len()); + for i in 0..meshes.len() { + let row = inside.get(i).ok_or_else(|| failed("missing containment"))?; + let candidates: Vec<_> = row.iter().enumerate().filter_map(|(j, hit)| hit.then_some(j)).collect(); + for (k, a) in candidates.iter().enumerate() { + for b in candidates.iter().skip(k + 1) { + if !inside.get(*a).and_then(|r| r.get(*b)).copied().unwrap_or(false) + && !inside.get(*b).and_then(|r| r.get(*a)).copied().unwrap_or(false) + { + return Err(failed("multiple overlapping shell owners")); + } + } + } + let owner = candidates.into_iter().min_by(|a, b| { + volumes.get(*a).copied().unwrap_or(f64::INFINITY).abs().total_cmp(&volumes.get(*b).copied().unwrap_or(f64::INFINITY).abs()) + }); + let outward = volumes.get(i).is_some_and(|v| *v > 0.0); + match owner { + None if !outward => return Err(failed("inward shell has no enclosing material shell")), + Some(p) if volumes.get(p).is_some_and(|v| (*v > 0.0) == outward) => { + return Err(failed("nested shells do not alternate material and void")); + } + _ => {} + } + parent.push(owner); + } + let mut groups = Vec::new(); + for (i, v) in volumes.iter().enumerate() { + if *v <= 0.0 { + continue; + } + let mut group = vec![i]; + group.extend(parent.iter().enumerate().filter_map(|(j, p)| (*p == Some(i)).then_some(j))); + groups.push(group); + } + Ok(groups) +} diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 2f904cd4..8cfaf654 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -878,7 +878,15 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul let shells = std::mem::take(&mut imp.shells); let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect()); Ok(( - Body { solid: std::sync::Arc::new(solid), coarse_missing: None, mesh: None, color: body.color, paint: None, splits: body.splits.clone() }, + Body { + solid: std::sync::Arc::new(solid), + coarse_missing: None, + mesh: None, + color: body.color, + paint: None, + splits: body.splits.clone(), + face_sources: None, + }, imp, )) }) diff --git a/crates/kernel/src/step.rs b/crates/kernel/src/step.rs index 0ab0169c..b6656ec7 100644 --- a/crates/kernel/src/step.rs +++ b/crates/kernel/src/step.rs @@ -7,8 +7,8 @@ use crate::{KernelError, Result, guard}; /// Intersection-curve edges (from booleans) as cubic B-splines through the exact curve: STEP /// readers get plain 3D geometry that lies on both faces, and the file does not repeat the two /// surfaces for every such edge. -fn plain_curves(b: &Body) -> mt::Solid { - let s = b.deep_copy(); +fn plain_curves(b: &Body) -> Result { + let s = crate::ops::placed_topology(&b.solid, mt::Matrix4::from_scale(1.0))?; let size = b.size(); let mut seen = std::collections::HashSet::new(); for e in s.edge_iter() { @@ -21,7 +21,7 @@ fn plain_curves(b: &Body) -> mt::Solid { e.set_curve(mt::Curve::BSplineCurve(c)); } } - s + Ok(s) } /// A C¹ cubic B-spline through exact points of `c` spaced so that chords deviate at most `tol` @@ -71,7 +71,7 @@ pub(crate) fn truck_step(bodies: &[&Body], system: &str) -> Result { b.require_brep("STEP export")?; } guard("step export", || { - let cs: Vec<_> = bodies.iter().map(|b| plain_curves(b).compress()).collect(); + let cs: Vec<_> = bodies.iter().map(|b| plain_curves(b).map(|solid| solid.compress())).collect::>()?; let models: out::StepModels<_, _, _> = cs.iter().collect(); let header = out::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() }; Ok(out::CompleteStepDisplay::new(models, header).to_string()) 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/mod.rs b/crates/kernel/src/step_in/mod.rs index 6f89fd40..794fdac3 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -34,6 +34,10 @@ const MAX_VISITS: usize = 100_000; /// A body read from a STEP file. #[derive(Clone, Debug)] pub struct ImportedBody { + /// Immutable STEP source item, scoped to its embedded payload. + pub source_item: u64, + /// Original STEP face entity of each kernel face, including seam pieces. + pub face_origin: Vec, /// Solid name (the brep's label), else the product name. pub name: String, pub body: Body, @@ -510,7 +514,21 @@ impl<'a> Reader<'a> { .collect(); let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces }; let body = body.with_color(color).with_paint(Some(paint)); - self.bodies.push(ImportedBody { name, body, color, path: path.to_vec(), closed, file_faces }); + let identities = face_origin + .iter() + .map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None })) + .collect(); + let body = body.with_face_sources(Some(std::sync::Arc::new(identities))); + self.bodies.push(ImportedBody { + name, + body, + color, + path: path.to_vec(), + closed, + file_faces, + source_item: item, + face_origin, + }); } Err(err) => { let label = self.label(item); 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/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 10be23d3..83f08f58 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -29,6 +29,7 @@ fn surface_body(faces: Vec) -> Body { color: None, paint: None, splits: None, + face_sources: None, } } @@ -45,6 +46,7 @@ fn body_of(faces: Vec) -> Result { color: None, paint: None, splits: None, + face_sources: None, }) } } diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs index debfbadc..595c4958 100644 --- a/crates/sketch/src/curves.rs +++ b/crates/sketch/src/curves.rs @@ -168,6 +168,64 @@ pub fn fit_beziers(p: &[Vec2]) -> Vec<[Vec2; 4]> { fit_spans(p).into_iter().map(|(p0, p1, t0, t1)| [p0, p0 + t0 / 3.0, p1 - t1 / 3.0, p1]).collect() } +/// Fit pieces in the explicitly selected interpolation mode. +pub fn fit_beziers_mode(p: &[Vec2], periodic: bool) -> crate::model::Result> { + if periodic { crate::periodic_beziers(p) } else { Ok(fit_beziers(p)) } +} +pub fn spline_point_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool, t: f64) -> Vec2 { + if !periodic { + return spline_point(p, control, degree, t); + } + let Ok(pieces) = crate::periodic_beziers(p) else { + return p.first().copied().unwrap_or_default(); + }; + let lengths: Vec = p.windows(2).map(|p| p[0].dist(p[1])).collect(); + let total = lengths.iter().sum::(); + let mut remaining = t.clamp(0.0, 1.0) * total; + let mut i = 0; + while i + 1 < pieces.len() && remaining > lengths[i] { + remaining -= lengths[i]; + i += 1; + } + let Some([a, b, c, d]) = pieces.get(i).copied() else { + return Vec2::ZERO; + }; + let t = (remaining / lengths[i]).clamp(0.0, 1.0); + a * (1.0 - t).powi(3) + b * (3.0 * t * (1.0 - t).powi(2)) + c * (3.0 * t * t * (1.0 - t)) + d * t.powi(3) +} +pub fn spline_polyline_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool) -> Vec { + if !periodic { + return spline_polyline(p, control, degree); + } + let Ok(pieces) = crate::periodic_beziers(p) else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(pieces.len() * SPAN_SAMPLES + 1); + for [a, b, c, d] in pieces { + for i in 0..SPAN_SAMPLES { + let t = i as f64 / SPAN_SAMPLES as f64; + out.push(a * (1.0 - t).powi(3) + b * (3.0 * t * (1.0 - t).powi(2)) + c * (3.0 * t * t * (1.0 - t)) + d * t.powi(3)); + } + } + if let Some(first) = p.first() { + out.push(*first); + } + out +} +pub fn spline_end_tangent_mode(p: &[Vec2], control: bool, periodic: bool, at_end: bool) -> Option { + if !periodic { + return spline_end_tangent(p, control, at_end); + } + let pieces = crate::periodic_beziers(p).ok()?; + if at_end { + let c = pieces.last()?; + (c[3] - c[2]).normalized() + } else { + let c = pieces.first()?; + (c[1] - c[0]).normalized() + } +} + /// The Bézier pieces (each `degree + 1` points) of the clamped uniform B-spline over `ctrl` /// (Boehm knot insertion until every interior knot has multiplicity `degree`). pub fn bspline_beziers(ctrl: &[Vec2], degree: usize) -> Vec> { diff --git a/crates/sketch/src/dims.rs b/crates/sketch/src/dims.rs index 164ca739..937e1314 100644 --- a/crates/sketch/src/dims.rs +++ b/crates/sketch/src/dims.rs @@ -59,6 +59,26 @@ pub fn dim_frame(sk: &Sketch, k: &ConstraintKind) -> Option { let (a, b) = line_ends(sk, l)?; DimFrame::Linear { p0: a, p1: b, dir: unit(b - a, Vec2::X) } } + LineAngle { l, value } => { + let (a, b) = line_ends(sk, l)?; + let d = unit(b - a, Vec2::X); + if value < 0.0 { + DimFrame::Angular { vertex: a, d0: d, sweep: (-value).rem_euclid(std::f64::consts::TAU) } + } else { + DimFrame::Angular { vertex: a, d0: Vec2::X, sweep: value.rem_euclid(std::f64::consts::TAU) } + } + } + ArcSweep { c, .. } => match sk.segs(c).first()? { + solvecraft_geom::Seg2::Arc { center, start, sweep, .. } => { + DimFrame::Angular { vertex: *center, d0: Vec2::from_angle(*start), sweep: *sweep } + } + _ => return None, + }, + SlotOverallLength { a, b, .. } => { + let (ca, cb) = (sk.center(a)?, sk.center(b)?); + let dir = unit(cb - ca, Vec2::X); + DimFrame::Linear { p0: ca - dir * sk.radius(a)?, p1: cb + dir * sk.radius(b)?, dir } + } PointLineDistance { p, l, .. } | LinearDiameter { p, l, .. } => { let (a, b) = line_ends(sk, l)?; let q = sk.point(p)?; diff --git a/crates/sketch/src/freeform_offset.rs b/crates/sketch/src/freeform_offset.rs new file mode 100644 index 00000000..fee33f0a --- /dev/null +++ b/crates/sketch/src/freeform_offset.rs @@ -0,0 +1,840 @@ +//! Persistent free-form offsets with a whole-span normal-distance error certificate. +//! The certificate bounds the second derivative of the exact normal offset and of +//! the actual cubic interpolant (natural for legacy output, periodic for new closed output). The endpoint interpolation theorem then +//! bounds their distance everywhere, rather than just at selected samples. +use crate::{CurveKind, Sketch, SketchError, fit_beziers_mode}; +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Seg2, Vec2}; + +pub const OFFSET_TOLERANCE: f64 = 0.002; +const MAX_PIECES: usize = 256; +const MAX_ASSOCIATIONS: usize = 128; +const MAX_OFFSET_WORK: usize = 1_048_576; +struct Fit { + tolerance: f64, + periodic: bool, +} +type Result = std::result::Result; +fn bad(message: &str) -> SketchError { + SketchError::Invalid(message.into()) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FreeformOffset { + pub source: String, + pub output: String, + pub forward: bool, + pub distance: f64, + /// Uniform dyadic subdivision count per exact source piece; preserves point roles. + pub divisions: usize, + pub pieces: usize, + pub closed: bool, + pub tolerance: f64, + pub certificate: u8, +} + +#[derive(Clone, Copy)] +struct I { + lo: f64, + hi: f64, +} +impl I { + fn value(v: f64) -> Self { + Self { lo: v, hi: v } + } + fn hull(values: impl Iterator) -> Self { + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for v in values { + lo = lo.min(v); + hi = hi.max(v); + } + let padding = 64.0 * f64::EPSILON * (lo.abs().max(hi.abs()) + 1.0); + Self { lo: lo - padding, hi: hi + padding } + } + fn add(self, b: Self) -> Self { + Self { lo: self.lo + b.lo, hi: self.hi + b.hi } + } + fn neg(self) -> Self { + Self { lo: -self.hi, hi: -self.lo } + } + fn sub(self, b: Self) -> Self { + self.add(b.neg()) + } + fn mul(self, b: Self) -> Self { + Self::hull([self.lo * b.lo, self.lo * b.hi, self.hi * b.lo, self.hi * b.hi].into_iter()) + } + fn scale(self, b: f64) -> Self { + self.mul(Self::value(b)) + } + fn div(self, b: Self) -> Result { + if b.lo <= 0.0 || !b.hi.is_finite() { + return Err(bad("uncertifiable rational offset denominator")); + } + Ok(self.mul(Self { lo: 1.0 / b.hi, hi: 1.0 / b.lo })) + } + fn abs_max(self) -> f64 { + self.lo.abs().max(self.hi.abs()) + } + fn abs_min(self) -> f64 { + if self.lo <= 0.0 && self.hi >= 0.0 { 0.0 } else { self.lo.abs().min(self.hi.abs()) } + } +} +#[derive(Clone, Copy)] +struct V { + x: I, + y: I, +} +impl V { + fn hull(values: &[Vec2]) -> Self { + Self { x: I::hull(values.iter().map(|v| v.x)), y: I::hull(values.iter().map(|v| v.y)) } + } + fn sub(self, b: Self) -> Self { + Self { x: self.x.sub(b.x), y: self.y.sub(b.y) } + } + fn mul(self, b: I) -> Self { + Self { x: self.x.mul(b), y: self.y.mul(b) } + } + fn div(self, b: I) -> Result { + Ok(Self { x: self.x.div(b)?, y: self.y.div(b)? }) + } + fn upper(self) -> f64 { + self.x.abs_max().hypot(self.y.abs_max()) + } + fn lower(self) -> f64 { + self.x.abs_min().hypot(self.y.abs_min()) + } + fn cross(self, b: Self) -> I { + self.x.mul(b.y).sub(self.y.mul(b.x)) + } +} + +/// Homogeneous controls retain the original parameter during restriction. Re-normalising +/// a conic with Seg2::split_at would change that parameter and invalidate the certificate. +#[derive(Clone)] +struct H { + n: Vec, + d: Vec, +} +impl H { + fn normal_end(&self, at_end: bool, distance: f64) -> Result { + let last = self.n.len() - 1; + let (i, j) = if at_end { (last, last - 1) } else { (0, 1) }; + let position = self.n[i] / self.d[i]; + let sign = if at_end { -1.0 } else { 1.0 }; + let dn = (self.n[j] - self.n[i]) * (last as f64 * sign); + let dd = (self.d[j] - self.d[i]) * (last as f64 * sign); + let tangent = ((dn - position * dd) / self.d[i]).normalized().ok_or_else(|| bad("offset source has a stationary tangent"))?; + Ok(position + tangent.perp() * distance) + } + fn from_seg(seg: &Seg2) -> Result { + let (points, weights) = match *seg { + Seg2::Line { a, b } => (vec![a, b], vec![1.0; 2]), + Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), + Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), + _ => return Err(bad("unsupported exact free-form offset piece")), + }; + if points.iter().any(|p| !p.is_finite()) { + return Err(bad("nonfinite offset source")); + } + let scale = weights.iter().copied().fold(1.0_f64, f64::max); + let d = weights.iter().map(|w| w / scale).collect::>(); + Ok(Self { n: points.iter().zip(&d).map(|(p, w)| *p * *w).collect(), d }) + } + fn split(&self, t: f64) -> (Self, Self) { + let (mut n, mut d) = (self.n.clone(), self.d.clone()); + let (mut ln, mut rn, mut ld, mut rd) = (vec![n[0]], vec![n[n.len() - 1]], vec![d[0]], vec![d[d.len() - 1]]); + for width in (1..n.len()).rev() { + for i in 0..width { + n[i] = n[i].lerp(n[i + 1], t); + d[i] = d[i] * (1.0 - t) + d[i + 1] * t; + } + ln.push(n[0]); + rn.push(n[width - 1]); + ld.push(d[0]); + rd.push(d[width - 1]); + } + rn.reverse(); + rd.reverse(); + (Self { n: ln, d: ld }, Self { n: rn, d: rd }) + } + fn range(&self, a: f64, b: f64) -> Self { + let left = if b < 1.0 { self.split(b).0 } else { self.clone() }; + if a > 0.0 { left.split(a / b).1 } else { left } + } + fn derivatives(&self) -> Result<[V; 4]> { + let origin = self.n[0] / self.d[0]; + let mut n = self.n.iter().zip(&self.d).map(|(n, d)| *n - origin * *d).collect::>(); + let mut d = self.d.clone(); + let mut nh = Vec::new(); + let mut dh = Vec::new(); + for _ in 0..4 { + nh.push(V::hull(&n)); + dh.push(I::hull(d.iter().copied())); + let degree = n.len().saturating_sub(1) as f64; + n = if n.len() > 1 { n.windows(2).map(|p| (p[1] - p[0]) * degree).collect() } else { vec![Vec2::ZERO] }; + d = if d.len() > 1 { d.windows(2).map(|p| (p[1] - p[0]) * degree).collect() } else { vec![0.0] }; + } + let r = nh[0].div(dh[0])?; + let v = nh[1].sub(r.mul(dh[1])).div(dh[0])?; + let a = nh[2].sub(v.mul(dh[1].scale(2.0))).sub(r.mul(dh[2])).div(dh[0])?; + let j = nh[3].sub(a.mul(dh[1].scale(3.0))).sub(v.mul(dh[2].scale(3.0))).sub(r.mul(dh[3])).div(dh[0])?; + Ok([r, v, a, j]) + } + fn offset_second_bound(&self, distance: f64) -> Result { + let [_, v, a, j] = self.derivatives()?; + let speed = v.lower(); + if speed <= 1e-12 { + return Err(bad("offset source has an unresolved stationary tangent")); + } + let curvature = v.cross(a).div(I { lo: speed.powi(3), hi: v.upper().powi(3) })?; + if curvature.scale(distance).hi >= 1.0 - 1e-8 { + return Err(bad("offset crosses a curvature cusp")); + } + let bound = a.upper() + distance.abs() * (2.0 * j.upper() / speed + 5.0 * (a.upper() / speed).powi(2)); + if !bound.is_finite() { + return Err(bad("offset derivative bound overflow")); + } + Ok(bound) + } +} + +/// Subdivision tightens the proof, not the stored geometry. Endpoint discrepancies +/// are measured against the exact rational normal, then the second derivative +/// interpolation bound certifies the full intervening interval. +fn certify(h: &H, cubic: &[Vec2; 4], distance: f64, tolerance: f64, depth: u8, budget: &mut usize) -> Result<()> { + if *budget == 0 { + return Err(bad("offset certificate work limit exceeded")); + } + *budget -= 1; + let endpoint_error = cubic[0].dist(h.normal_end(false, distance)?).max(cubic[3].dist(h.normal_end(true, distance)?)); + if endpoint_error + 1e-10 > tolerance { + return Err(bad("offset spline exceeds its endpoint error certificate")); + } + let cubic_second = ((cubic[2] - cubic[1] * 2.0 + cubic[0]) * 6.0).len().max(((cubic[3] - cubic[2] * 2.0 + cubic[1]) * 6.0).len()); + if h.offset_second_bound(distance).is_ok_and(|second| endpoint_error + (second + cubic_second) / 8.0 * (1.0 + 1e-10) + 1e-10 <= tolerance) { + return Ok(()); + } + if depth >= 8 { + return Err(bad("offset spline exceeds its whole-span error or regularity certificate")); + } + let (left, right) = h.split(0.5); + let curve = Seg2::Cubic { p0: cubic[0], p1: cubic[1], p2: cubic[2], p3: cubic[3] }; + let (a, b) = curve.split_half(); + let controls = |s: Seg2| match s { + Seg2::Cubic { p0, p1, p2, p3 } => Ok([p0, p1, p2, p3]), + _ => Err(bad("invalid cubic certificate")), + }; + certify(&left, &controls(a)?, distance, tolerance, depth + 1, budget)?; + certify(&right, &controls(b)?, distance, tolerance, depth + 1, budget) +} + +fn normal_point(seg: &Seg2, t: f64, d: f64) -> Result { + let normal = seg.derivative(t).normalized().ok_or_else(|| bad("offset source has a stationary tangent"))?.perp(); + let p = seg.point_at(t) + normal * d; + if !p.is_finite() { + return Err(bad("nonfinite offset point")); + } + Ok(p) +} + +fn verify_simple(points: &[Vec2], cubics: &[[Vec2; 4]], closed: bool) -> Result<()> { + // Each cubic's derivative controls must lie in the forward half-plane of its + // chord. This proves local injectivity; separated convex hulls prove distant + // spans cannot intersect. Near or ambiguous contacts are refused atomically. + for c in cubics { + let chord = c[3] - c[0]; + if c.windows(2).any(|p| (p[1] - p[0]).dot(chord) <= 1e-18) { + return Err(bad("offset spline cannot be certified locally simple")); + } + } + // Adjacent spans need their own proof: individual monotonicity does not + // preclude them crossing each other away from their common endpoint. + let separates_joint = |a: &[Vec2; 4], b: &[Vec2; 4]| { + let joint = a[3]; + let direction = (a[3] - a[0]) + (b[3] - b[0]); + joint.dist(b[0]) < 1e-7 + && a[..3].iter().all(|p| (*p - joint).dot(direction) < -1e-18) + && b[1..].iter().all(|p| (*p - joint).dot(direction) > 1e-18) + }; + for pair in cubics.windows(2) { + if !separates_joint(&pair[0], &pair[1]) { + return Err(bad("offset junction cannot be certified simple")); + } + } + if closed && cubics.first().zip(cubics.last()).is_some_and(|(first, last)| !separates_joint(last, first)) { + return Err(bad("offset seam cannot be certified simple")); + } + for i in 0..cubics.len() { + for j in i + 2..cubics.len() { + if closed && i == 0 && j + 1 == cubics.len() { + continue; + } + let a = V::hull(&cubics[i]); + let b = V::hull(&cubics[j]); + if a.x.hi < b.x.lo || b.x.hi < a.x.lo || a.y.hi < b.y.lo || b.y.hi < a.y.lo { + continue; + } + // A separating axis from either chord also separates both convex hulls. + let separated = [points[i + 1] - points[i], points[j + 1] - points[j]].iter().any(|d| { + let n = d.perp(); + let a = I::hull(cubics[i].iter().map(|p| p.dot(n))); + let b = I::hull(cubics[j].iter().map(|p| p.dot(n))); + a.hi < b.lo || b.hi < a.lo + }); + if !separated { + return Err(bad("offset self-intersection or ambiguous close contact")); + } + } + } + Ok(()) +} + +fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, fit: Fit, work: &mut usize) -> Result<(Vec, usize, bool)> { + if *work == 0 { + return Err(bad("offset transaction certificate work limit exceeded")); + } + let Fit { tolerance, periodic } = fit; + let mut pieces = sk.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?; + if pieces.is_empty() + || pieces.len() > MAX_PIECES + || divisions == 0 + || !divisions.is_power_of_two() + || divisions > 256 + || pieces.len().saturating_mul(divisions) >= crate::MAX_SPLINE_POINTS + || !d.is_finite() + || d.abs() < 1e-9 + || !tolerance.is_finite() + || tolerance <= 0.0 + || tolerance > OFFSET_TOLERANCE + { + return Err(bad("invalid or excessive certified offset")); + } + if !forward { + pieces.reverse(); + pieces = pieces.into_iter().map(|p| p.reversed()).collect(); + } + let mut points = Vec::new(); + let mut restricted = Vec::new(); + for piece in &pieces { + let homogeneous = H::from_seg(piece)?; + let first = normal_point(piece, 0.0, d)?; + if points.last().is_some_and(|p: &Vec2| p.dist(first) > 1e-7) { + return Err(bad("free-form offset has a nonsmooth source junction")); + } + if points.is_empty() { + points.push(first); + } + for i in 0..divisions { + let (a, b) = (i as f64 / divisions as f64, (i + 1) as f64 / divisions as f64); + restricted.push(homogeneous.range(a, b)); + let end = normal_point(piece, b, d)?; + points.push(end); + } + } + let closed = pieces.first().is_some_and(|a| pieces.last().is_some_and(|b| a.point_at(0.0).dist(b.point_at(1.0)) < 1e-7)); + if closed && points[0].dist(points[points.len() - 1]) > 1e-7 { + return Err(bad("free-form closed offset has a nonsmooth seam")); + } + if closed { + let first = points[0]; + if let Some(last) = points.last_mut() { + *last = first; + } + } + let cubics = fit_beziers_mode(&points, periodic && closed)?; + let allowance = (*work).min(65_536); + let mut budget = allowance; + let result = restricted.iter().zip(&cubics).try_for_each(|(h, c)| certify(h, c, d, tolerance, 0, &mut budget)); + *work -= allowance - budget; + result?; + verify_simple(&points, &cubics, closed)?; + Ok((points, pieces.len(), closed)) +} + +impl Sketch { + /// Validate persisted association roles without solving or certifying geometry. + pub fn validate_freeform_offsets(&self) -> Result<()> { + if self.freeform_offsets.is_empty() { + return Ok(()); + } + if self.freeform_offsets.len() > MAX_ASSOCIATIONS { + return Err(bad("too many associative free-form offsets")); + } + if self.points.len() > 100_000 || self.curves.len() > 100_000 { + return Err(bad("offset sketch exceeds ownership validation limit")); + } + let mut owners = vec![0usize; self.points.len()]; + let mut references = 0usize; + for curve in &self.curves { + if matches!(&curve.kind, CurveKind::Spline {pts,..} if pts.len() > crate::MAX_SPLINE_POINTS) { + return Err(bad("offset sketch contains an excessive spline")); + } + for index in curve.kind.point_ids() { + references += 1; + if references > 1_000_000 { + return Err(bad("offset point ownership work limit exceeded")); + } + if let Some(count) = owners.get_mut(index) { + *count += 1; + } + } + } + let mut outputs = std::collections::BTreeSet::new(); + for record in &self.freeform_offsets { + if record.source.is_empty() + || record.source.len() > 512 + || record.output.is_empty() + || record.output.len() > 512 + || record.source == record.output + || !matches!(record.certificate, 1 | 2) + || record.pieces == 0 + || record.pieces > MAX_PIECES + || record.divisions == 0 + || !record.divisions.is_power_of_two() + || record.divisions > 256 + || record.pieces.saturating_mul(record.divisions) >= crate::MAX_SPLINE_POINTS + || !record.distance.is_finite() + || record.distance.abs() < 1e-9 + || !record.tolerance.is_finite() + || record.tolerance <= 0.0 + || record.tolerance > OFFSET_TOLERANCE + || !outputs.insert(record.output.as_str()) + || self.freeform_offsets.iter().any(|other| other.output == record.source && !outputs.contains(other.output.as_str())) + { + return Err(bad("invalid cyclic or unbounded offset association")); + } + let source = self.curve_index(&record.source).ok_or_else(|| bad("offset source vanished"))?; + if !matches!(self.curves[source].kind, CurveKind::Ellipse { .. } | CurveKind::Conic { .. } | CurveKind::Spline { .. }) { + return Err(bad("offset source changed shape")); + } + let output = self.curve_index(&record.output).ok_or_else(|| bad("offset output vanished"))?; + match &self.curves[output].kind { + CurveKind::Spline { pts, control: false, degree: 3, periodic } + if *periodic == (record.certificate == 2) + && (record.certificate != 2 || record.closed) + && pts.len() == record.pieces * record.divisions + 1 + && pts.iter().all(|i| *i < self.points.len()) + && pts.iter().copied().collect::>().len() == pts.len() + && pts + .iter() + .all(|i| self.points.get(*i).is_some_and(|point| point.fixed && point.link.is_none()) && owners.get(*i) == Some(&1)) => {} + _ => return Err(bad("invalid offset output point roles")), + } + } + Ok(()) + } + pub fn add_freeform_offset(&mut self, source: usize, forward: bool, distance: f64) -> Result { + if self.freeform_offsets.len() >= MAX_ASSOCIATIONS { + return Err(bad("too many associative free-form offsets")); + } + let source_id = self.curves.get(source).ok_or_else(|| bad("missing offset source"))?.id.clone(); + let source_pieces = self.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?.len(); + let mut result = None; + let mut failure = None; + let mut work = MAX_OFFSET_WORK; + for divisions in [1, 2, 4, 8, 16, 32, 64, 128, 256] { + if source_pieces.saturating_mul(divisions) >= crate::MAX_SPLINE_POINTS { + break; + } + match geometry(self, source, forward, distance, divisions, Fit { tolerance: OFFSET_TOLERANCE, periodic: true }, &mut work) { + Ok(g) => { + result = Some((divisions, g)); + break; + } + Err(error) => { + failure = Some(error); + } + } + } + let (divisions, (points, pieces, closed)) = result.ok_or_else(|| failure.unwrap_or_else(|| bad("offset subdivision limit exceeded")))?; + let mut candidate = self.clone(); + let indices = points.iter().map(|p| candidate.add_point(*p, None)).collect::>>()?; + for i in &indices { + if let Some(p) = candidate.points.get_mut(*i) { + p.fixed = true; + } + } + let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: closed }, None)?; + candidate.freeform_offsets.push(FreeformOffset { + source: source_id, + output: candidate.curves[output].id.clone(), + forward, + distance, + divisions, + pieces, + closed, + tolerance: OFFSET_TOLERANCE, + certificate: if closed { 2 } else { 1 }, + }); + *self = candidate; + Ok(output) + } + + pub(crate) fn refresh_freeform_offsets(&mut self) -> Result<()> { + self.validate_freeform_offsets()?; + let mut candidate = self.clone(); + let mut seen = std::collections::BTreeSet::new(); + let mut work = MAX_OFFSET_WORK; + for (record_index, record) in self.freeform_offsets.clone().into_iter().enumerate() { + if !matches!(record.certificate, 1 | 2) + || record.source == record.output + || !seen.insert(record.output.clone()) + || candidate.freeform_offsets.iter().any(|later| later.output == record.source && !seen.contains(&later.output)) + { + return Err(bad("invalid cyclic offset association")); + } + let source = candidate.curve_index(&record.source).ok_or_else(|| bad("offset source vanished"))?; + let output = candidate.curve_index(&record.output).ok_or_else(|| bad("offset output vanished"))?; + let source_pieces = candidate.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?.len(); + let mut divisions = record.divisions; + if source_pieces != record.pieces { + let lineage = candidate.freeform_offsets.iter().any(|parent| parent.output == record.source); + if !lineage || source_pieces < record.pieces || source_pieces % record.pieces != 0 { + return Err(bad("offset source topology changed outside its refinement lineage")); + } + let ratio = source_pieces / record.pieces; + if !ratio.is_power_of_two() { + return Err(bad("offset source refinement has incompatible point roles")); + } + divisions = (record.divisions / ratio).max(1); + } + let (points, pieces, closed) = loop { + match geometry( + &candidate, + source, + record.forward, + record.distance, + divisions, + Fit { tolerance: record.tolerance, periodic: record.certificate == 2 }, + &mut work, + ) { + Ok(geometry) => break geometry, + Err(error) => { + if divisions >= 256 || source_pieces.saturating_mul(divisions.saturating_mul(2)) >= crate::MAX_SPLINE_POINTS { + return Err(bad(&format!("offset refinement limit reached while certifying its chain: {error}"))); + } + divisions *= 2; + } + } + }; + if closed != record.closed { + return Err(bad("offset source topology changed")); + } + let old_indices = match &candidate.curves[output].kind { + CurveKind::Spline { pts, control: false, degree: 3, periodic } + if *periodic == (record.certificate == 2) + && (record.certificate != 2 || record.closed) + && pts.len() == record.pieces * record.divisions + 1 => + { + pts.clone() + } + _ => return Err(bad("offset output was edited; break its association first")), + }; + // Refinement never reuses or removes an old node ID. Existing dyadic + // roles remain at the same normalized source parameters; new nodes + // append to the point table, so dimensions keep their point indices. + let old_spans = record.pieces * record.divisions; + let new_spans = pieces * divisions; + if new_spans < old_spans || new_spans % old_spans != 0 || !(new_spans / old_spans).is_power_of_two() { + return Err(bad("offset refinement would change existing normalized point roles")); + } + let factor = new_spans / old_spans; + let mut indices = Vec::with_capacity(points.len()); + for (role, pos) in points.iter().enumerate() { + let index = if role % factor == 0 { + *old_indices.get(role / factor).ok_or_else(|| bad("offset node role vanished"))? + } else { + candidate.add_point(*pos, None)? + }; + indices.push(index); + } + for (i, pos) in indices.iter().zip(points) { + let p = candidate.points.get_mut(*i).ok_or_else(|| bad("offset output point vanished"))?; + p.pos = pos; + p.fixed = true; + } + candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: record.certificate == 2 }; + candidate.freeform_offsets[record_index].divisions = divisions; + candidate.freeform_offsets[record_index].pieces = pieces; + } + *self = candidate; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ellipse() -> (Sketch, usize) { + let mut sketch = Sketch::new(); + let center = sketch.add_point(Vec2::ZERO, None).unwrap(); + let major = sketch.add_point(Vec2::new(10.0, 0.0), None).unwrap(); + let source = sketch.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap(); + (sketch, source) + } + + #[test] + fn certified_offset_ellipse_refreshes_exact_normals_and_identity() { + let (mut sk, source) = ellipse(); + let output = sk.add_freeform_offset(source, true, -1.0).unwrap(); + let record = sk.freeform_offsets[0].clone(); + let original_ids = sk.curves[output].kind.point_ids().iter().map(|i| sk.points[*i].id.clone()).collect::>(); + let verify = |sk: &Sketch| { + let record = &sk.freeform_offsets[0]; + let base = sk.exact_segs(source).unwrap(); + let curve = sk.exact_segs(output).unwrap(); + for (piece, source_piece) in base.iter().enumerate() { + for span in 0..record.divisions { + for t in [0.0, 0.13, 0.41, 0.79, 1.0] { + let want = normal_point(source_piece, (span as f64 + t) / record.divisions as f64, -1.0).unwrap(); + let got = curve[piece * record.divisions + span].point_at(t); + assert!(got.dist(want) <= OFFSET_TOLERANCE, "{got:?} vs {want:?}"); + } + } + } + }; + verify(&sk); + // Independent Steiner area check for the convex ellipse expanded by 1mm. + // Simpson integration evaluates the analytic ellipse speed, not offset samples. + let n = 4096usize; + let h = std::f64::consts::TAU / n as f64; + let speed = |t: f64| (100.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt(); + let perimeter = (0..=n) + .map(|i| { + speed(i as f64 * h) + * if i == 0 || i == n { + 1.0 + } else if i % 2 == 0 { + 2.0 + } else { + 4.0 + } + }) + .sum::() + * h + / 3.0; + let expected_area = 50.0 * std::f64::consts::PI + perimeter + std::f64::consts::PI; + let actual_area = sk.exact_segs(output).unwrap().iter().map(Seg2::area_term).sum::(); + assert!((actual_area - expected_area).abs() < 0.15, "area {actual_area} vs {expected_area}"); + if let CurveKind::Ellipse { m, .. } = sk.curves[source].kind { + sk.points[m].pos = Vec2::new(12.0, 0.0); + } + sk.refresh_offsets().unwrap(); + assert!(sk.freeform_offsets[0].divisions >= record.divisions); + let factor = sk.freeform_offsets[0].divisions / record.divisions; + assert_eq!(sk.curves[output].kind.point_ids().iter().step_by(factor).map(|i| sk.points[*i].id.clone()).collect::>(), original_ids); + verify(&sk); + let first = sk.segs(output)[0].point_at(0.0); + assert!(first.dist(Vec2::new(13.0, 0.0)) < 1e-10); + let encoded = serde_json::to_string(&sk).unwrap(); + let mut reopened: Sketch = serde_json::from_str(&encoded).unwrap(); + reopened.refresh_offsets().unwrap(); + assert_eq!(reopened, sk); + } + + #[test] + fn certified_offset_refuses_cusp_and_hostile_records_atomically() { + let (mut sk, source) = ellipse(); + let before = sk.clone(); + // At (10,0), curvature radius is b²/a=2.5. An inward offset of 3 + // necessarily crosses the evolute and reverses its tangent orientation. + assert!(sk.add_freeform_offset(source, true, 3.0).is_err()); + assert_eq!(sk, before); + sk.add_freeform_offset(source, true, -1.0).unwrap(); + for divisions in [0, 3, usize::MAX] { + let mut candidate = sk.clone(); + candidate.freeform_offsets[0].divisions = divisions; + let before = candidate.clone(); + assert!(candidate.refresh_offsets().is_err()); + assert_eq!(candidate, before); + } + let mut cyclic = sk.clone(); + cyclic.freeform_offsets[0].source = cyclic.freeform_offsets[0].output.clone(); + let before = cyclic.clone(); + assert!(cyclic.refresh_offsets().is_err()); + assert_eq!(cyclic, before); + let source_points = sk.curves[source].kind.point_ids(); + let output = sk.curve_index(&sk.freeform_offsets[0].output).unwrap(); + let mut aliased = sk.clone(); + if let CurveKind::Spline { pts, .. } = &mut aliased.curves[output].kind { + pts[0] = source_points[0]; + } + let before = aliased.clone(); + assert!(aliased.validate_freeform_offsets().is_err()); + assert!(aliased.refresh_offsets().is_err()); + assert_eq!(aliased, before); + } + + #[test] + fn certified_offset_chain_preserves_roles_during_upstream_refinement() { + let mut sk = Sketch::new(); + let start = sk.add_point(Vec2::new(10.0, 0.0), None).unwrap(); + let apex = sk.add_point(Vec2::new(10.0, 5.0), None).unwrap(); + let end = sk.add_point(Vec2::new(0.0, 5.0), None).unwrap(); + let w = std::f64::consts::FRAC_1_SQRT_2; + let source = sk.add_curve(CurveKind::Conic { a: start, b: end, apex, rho: w / (1.0 + w) }, None).unwrap(); + let a = sk.add_freeform_offset(source, true, -1.0).unwrap(); + let b = sk.add_freeform_offset(a, true, -1.0).unwrap(); + let original = [a, b].map(|i| sk.curves[i].kind.point_ids().iter().map(|point| sk.points[*point].id.clone()).collect::>()); + let records = sk.freeform_offsets.clone(); + for major in [12.0, 16.0, 20.0, 24.0] { + sk.points[start].pos.x = major; + sk.points[apex].pos.x = major; + sk.refresh_offsets().unwrap(); + if sk.freeform_offsets[0].divisions > records[0].divisions { + break; + } + } + assert!(sk.freeform_offsets[0].divisions > records[0].divisions, "test must exercise real upstream refinement"); + for (record_index, output) in [a, b].into_iter().enumerate() { + let old = &records[record_index]; + let current = &sk.freeform_offsets[record_index]; + let factor = current.pieces * current.divisions / (old.pieces * old.divisions); + assert_eq!( + sk.curves[output].kind.point_ids().iter().step_by(factor).map(|i| sk.points[*i].id.clone()).collect::>(), + original[record_index] + ); + let source = sk.curve_index(¤t.source).unwrap(); + let source_pieces = sk.exact_segs(source).unwrap(); + let output_pieces = sk.exact_segs(output).unwrap(); + for (piece, source) in source_pieces.iter().enumerate() { + for span in 0..current.divisions { + let t = 0.371; + let want = normal_point(source, (span as f64 + t) / current.divisions as f64, current.distance).unwrap(); + assert!(output_pieces[piece * current.divisions + span].point_at(t).dist(want) < OFFSET_TOLERANCE); + } + } + } + let saved = serde_json::to_string(&sk).unwrap(); + let mut reopened: Sketch = serde_json::from_str(&saved).unwrap(); + reopened.refresh_offsets().unwrap(); + assert_eq!(reopened, sk); + } + + #[test] + fn certified_offset_closed_chain_refuses_natural_spline_seam_atomically() { + let (mut sk, source) = ellipse(); + let first = sk.add_freeform_offset(source, true, -1.0).unwrap(); + // Explicitly reconstruct the old saved semantics, not the new periodic output. + if let CurveKind::Spline { periodic, .. } = &mut sk.curves[first].kind { + *periodic = false; + } + sk.freeform_offsets[0].certificate = 1; + sk.refresh_offsets().unwrap(); + let before = sk.clone(); + let error = sk.add_freeform_offset(first, true, -0.5).unwrap_err(); + assert!(error.to_string().contains("nonsmooth seam"), "{error}"); + assert_eq!(sk, before); + } + + #[test] + fn certified_offset_periodic_closed_chain_has_c2_seams_and_stable_roles() { + let (mut sk, source) = ellipse(); + let source_points = sk.curves[source].kind.point_ids(); + let unchanged = source_points.iter().map(|i| sk.points[*i].clone()).collect::>(); + let a = sk.add_freeform_offset(source, true, -1.0).unwrap(); + let b = sk.add_freeform_offset(a, true, -0.5).unwrap(); + assert_eq!(source_points.iter().map(|i| sk.points[*i].clone()).collect::>(), unchanged); + let old = sk.freeform_offsets.clone(); + let ids = [a, b].map(|i| sk.curves[i].kind.point_ids().iter().map(|p| sk.points[*p].id.clone()).collect::>()); + let verify = |sk: &Sketch| { + for output in [a, b] { + let record = sk.freeform_offsets.iter().find(|r| r.output == sk.curves[output].id).unwrap(); + assert_eq!(record.certificate, 2); + let points = sk.curves[output].kind.point_ids().iter().map(|p| sk.point(*p).unwrap()).collect::>(); + let cubics = crate::periodic_beziers(&points).unwrap(); + let first = cubics[0]; + let last = *cubics.last().unwrap(); + let (h0, h1) = (points[0].dist(points[1]), points[points.len() - 2].dist(points[points.len() - 1])); + assert_eq!(first[0], last[3]); + assert!(((first[1] - first[0]) / h0 - (last[3] - last[2]) / h1).len() < 1e-10); + assert!(((first[2] - first[1] * 2.0 + first[0]) / (h0 * h0) - (last[3] - last[2] * 2.0 + last[1]) / (h1 * h1)).len() < 1e-9); + let source = sk.curve_index(&record.source).unwrap(); + for (i, piece) in sk.exact_segs(source).unwrap().iter().enumerate() { + for j in 0..record.divisions { + for t in [0.0, 0.13, 0.41, 0.79, 1.0] { + let exact = normal_point(piece, (j as f64 + t) / record.divisions as f64, record.distance).unwrap(); + let got = sk.exact_segs(output).unwrap()[i * record.divisions + j].point_at(t); + assert!(got.dist(exact) <= OFFSET_TOLERANCE); + if source == 0 { + // Independent ellipse implicit-gradient normal, rather + // than using the offset implementation as its own oracle. + let base = piece.point_at((j as f64 + t) / record.divisions as f64); + let major = sk.point(source_points[1]).unwrap().x; + let normal = Vec2::new(base.x / (major * major), base.y / 25.0).normalized().unwrap(); + assert!(got.dist(base + normal) <= OFFSET_TOLERANCE); + } + } + } + } + } + }; + verify(&sk); + // Steiner's formula is independent of fitted output: both offsets expand + // the original ellipse by 1.5mm, within the accumulated two certificates. + let n = 4096; + let h = std::f64::consts::TAU / n as f64; + let perimeter = (0..=n) + .map(|i| { + let t = i as f64 * h; + (100.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt() + * if i == 0 || i == n { + 1.0 + } else if i % 2 == 0 { + 2.0 + } else { + 4.0 + } + }) + .sum::() + * h + / 3.0; + let expected = 50.0 * std::f64::consts::PI + 1.5 * perimeter + 2.25 * std::f64::consts::PI; + let area = sk.exact_segs(b).unwrap().iter().map(Seg2::area_term).sum::(); + assert!((area - expected).abs() < 0.03, "{area} vs {expected}"); + let profiles = crate::find_profiles(&sk); + assert_eq!(profiles.len(), 3); + if let CurveKind::Ellipse { m, .. } = sk.curves[source].kind { + sk.points[m].pos.x = 12.0; + } + sk.refresh_offsets().unwrap(); + verify(&sk); + for (j, i) in [a, b].into_iter().enumerate() { + let now = &sk.freeform_offsets[j]; + let factor = now.pieces * now.divisions / (old[j].pieces * old[j].divisions); + assert_eq!(sk.curves[i].kind.point_ids().iter().step_by(factor).map(|p| sk.points[*p].id.clone()).collect::>(), ids[j]); + } + let encoded = serde_json::to_string(&sk).unwrap(); + let mut reopened: Sketch = serde_json::from_str(&encoded).unwrap(); + reopened.validate_periodic_splines().unwrap(); + reopened.refresh_offsets().unwrap(); + assert_eq!(sk, reopened); + } + + #[test] + fn certified_offset_periodic_circle_and_reverse_have_analytic_normals() { + for forward in [true, false] { + let mut sk = Sketch::new(); + let center = sk.add_point(Vec2::ZERO, None).unwrap(); + let major = sk.add_point(Vec2::new(5.0, 0.0), None).unwrap(); + let source = sk.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap(); + let distance = if forward { -1.0 } else { 1.0 }; + let a = sk.add_freeform_offset(source, forward, distance).unwrap(); + let b = sk.add_freeform_offset(a, true, distance * 0.5).unwrap(); + for (output, radius) in [(a, 6.0), (b, 6.5)] { + let pieces = sk.exact_segs(output).unwrap(); + for curve in &pieces { + for t in [0.0, 0.13, 0.41, 0.79, 1.0] { + assert!((curve.point_at(t).len() - radius).abs() <= 2.0 * OFFSET_TOLERANCE); + } + } + let area = pieces.iter().map(Seg2::area_term).sum::().abs(); + assert!((area - std::f64::consts::PI * radius * radius).abs() < 0.03); + } + sk.refresh_offsets().unwrap(); + sk.validate_periodic_splines().unwrap(); + } + } +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 3079d6db..e48cccdd 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -10,10 +10,12 @@ mod curves; mod dims; +mod freeform_offset; mod linalg; mod link; mod model; mod offset; +mod periodic; mod profiles; mod shape; mod solver; @@ -21,17 +23,20 @@ mod text; mod wire; pub use curves::{ - MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, spline_end_tangent, spline_point, spline_polyline, + MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, spline_end_tangent, + spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode, }; pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text}; +pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView}; pub use offset::OffsetChain; +pub use periodic::periodic_beziers; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use shape::{Shape, intersections}; pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding}; pub use text::{MAX_TEXT_CHARS, text_geometry}; -pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline}; +pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline}; #[cfg(test)] mod tests; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index c02ce3da..274c9a0b 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -13,6 +13,9 @@ use crate::model::{Curve, CurveKind, Result, Sketch, SketchError}; /// Points of one link closer than this are merged into one point. const MERGE_TOL: f64 = 1e-7; + +#[path = "link_loops.rs"] +mod loops; /// Most entities a single link may produce. pub const MAX_LINK_ENTITIES: usize = 20_000; @@ -107,6 +110,9 @@ pub struct Link { /// Persistent model face identity, resolved by the document before geometry refresh. #[serde(default, skip_serializing_if = "Option::is_none")] pub face_name: Option, + /// Automatically projected borders owned by a durable sketch face attachment. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub auto_face: bool, /// The reference could not be found on the last recompute; the geometry is the last good one. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub lost: bool, @@ -142,6 +148,7 @@ pub enum LinkGeom { pts: Vec, control: bool, degree: u8, + periodic: bool, }, } @@ -157,8 +164,11 @@ impl LinkGeom { LinkGeom::Ellipse { c, major, minor } => { ok(c) && ok(major) && c.dist(*major) > 1e-9 && minor.is_finite() && *minor > 1e-9 && *minor < 1e9 } - LinkGeom::Spline { pts, degree, .. } => { - (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && pts.iter().all(ok) && (1..=7).contains(degree) + LinkGeom::Spline { pts, control, degree, periodic } => { + (!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok())) + && (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) + && pts.iter().all(ok) + && (1..=7).contains(degree) } } } @@ -181,7 +191,7 @@ enum LCurve { Ellipse(usize, usize, f64), /// Start, end, first interior point and interior count (interior points are consecutive /// and never shared), control flag, degree. - Spline(usize, usize, usize, usize, bool, u8), + Spline(usize, usize, usize, usize, bool, u8, bool), } fn layout(geom: &[LinkGeom]) -> Result { @@ -234,14 +244,14 @@ fn layout(geom: &[LinkGeom]) -> Result { l.curves.push(LCurve::Ellipse(c, m, minor)); } } - LinkGeom::Spline { pts, control, degree } => { + LinkGeom::Spline { pts, control, degree, periodic } => { let (Some(first), Some(last)) = (pts.first(), pts.last()) else { continue }; let s0 = pt(&mut l.pts, *first); let first_in = l.pts.len(); let inner = pts.get(1..pts.len() - 1).unwrap_or_default(); l.pts.extend(inner.iter().copied()); let s1 = pt(&mut l.pts, *last); - l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree)); + l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree, periodic)); } } } @@ -293,13 +303,20 @@ fn same_geometry(a: &[(LCurve, Vec)], b: &[(LCurve, Vec)]) -> bool { (LCurve::Arc(..), LCurve::Arc(..)) => all(), (LCurve::Conic(.., ra), LCurve::Conic(.., rb)) => all() && close(*ra, *rb), (LCurve::Ellipse(.., ra), LCurve::Ellipse(.., rb)) => all() && close(*ra, *rb), - (LCurve::Spline(.., ca, da), LCurve::Spline(.., cb, db)) => ca == cb && da == db && all(), + (LCurve::Spline(.., ca, da, pa), LCurve::Spline(.., cb, db, pb)) => ca == cb && da == db && pa == pb && all(), _ => false, } }; 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 +347,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) @@ -371,7 +430,7 @@ impl Sketch { return Err(SketchError::TooLarge); } let id = self.fresh("j"); - self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false }); + self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false }); self.build_link(&id, &lay)?; Ok(id) } @@ -400,13 +459,13 @@ impl Sketch { LCurve::Arc(c, a, b) => (CurveKind::Arc { c: point(self, c)?, a: point(self, a)?, b: point(self, b)? }, "a"), LCurve::Conic(a, b, x, rho) => (CurveKind::Conic { a: point(self, a)?, b: point(self, b)?, apex: point(self, x)?, rho }, "k"), LCurve::Ellipse(c, m, r) => (CurveKind::Ellipse { c: point(self, c)?, m: point(self, m)?, r }, "e"), - LCurve::Spline(a, b, f, n, control, degree) => { + LCurve::Spline(a, b, f, n, control, degree, periodic) => { let mut pts = vec![point(self, a)?]; for i in f..f + n { pts.push(point(self, i)?); } pts.push(point(self, b)?); - (CurveKind::Spline { pts, control, degree }, "s") + (CurveKind::Spline { pts, control, degree, periodic }, "s") } }; let cid = self.fresh(prefix); @@ -435,6 +494,16 @@ impl Sketch { return Err(SketchError::Unknown(id.into())); } let lay = layout(geom)?; + if self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) + && lay.lone.is_empty() + && lay.curves.len() <= 512 + && lay.pts.len() <= 512 + && lay.curves.iter().all(|c| matches!(c, LCurve::Line(..) | LCurve::Arc(..) | LCurve::Circle(..))) + && !loops::valid(&lay.curves, &lay.pts) + { + self.set_link_lost(id, true); + return Err(SketchError::Invalid("projected face boundaries cross, touch or have ambiguous topology; last geometry retained".into())); + } let curves = self.link_curves(id); let points = self.link_points(id); // Current layout of the link, in the same terms. @@ -447,7 +516,7 @@ impl Sketch { Some(CurveKind::Arc { c, a, b }) => Some(LCurve::Arc(local(*c)?, local(*a)?, local(*b)?)), Some(CurveKind::Conic { a, b, apex, rho }) => Some(LCurve::Conic(local(*a)?, local(*b)?, local(*apex)?, *rho)), Some(CurveKind::Ellipse { c, m, r }) => Some(LCurve::Ellipse(local(*c)?, local(*m)?, *r)), - Some(CurveKind::Spline { pts, control, degree }) => { + Some(CurveKind::Spline { pts, control, degree, periodic }) => { let (a, b) = (local(*pts.first()?)?, local(*pts.last()?)?); let inner: Vec = pts.get(1..pts.len().saturating_sub(1)).unwrap_or_default().iter().map(|q| local(*q)).collect::>()?; @@ -456,7 +525,7 @@ impl Sketch { if inner.iter().enumerate().any(|(k, x)| *x != f + k) { return None; } - Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree)) + Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree, *periodic)) } _ => None, }) @@ -512,7 +581,7 @@ impl Sketch { LCurve::Arc(c, a, b) => LCurve::Arc(renum(c), renum(a), renum(b)), LCurve::Conic(a, b, x, r) => LCurve::Conic(renum(a), renum(b), renum(x), r), LCurve::Ellipse(c, m, r) => LCurve::Ellipse(renum(c), renum(m), r), - LCurve::Spline(a, b, f, n, ct, d) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d), + LCurve::Spline(a, b, f, n, ct, d, periodic) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d, periodic), }) .collect(); let same = match &cur { @@ -527,8 +596,8 @@ impl Sketch { (LCurve::Arc(a0, a1, a2), LCurve::Arc(b0, b1, b2)) => a0 == b0 && a1 == b1 && a2 == b2, (LCurve::Conic(a0, a1, a2, _), LCurve::Conic(b0, b1, b2, _)) => a0 == b0 && a1 == b1 && a2 == b2, (LCurve::Ellipse(a0, a1, _), LCurve::Ellipse(b0, b1, _)) => a0 == b0 && a1 == b1, - (LCurve::Spline(a0, a1, a2, a3, a4, a5), LCurve::Spline(b0, b1, b2, b3, b4, b5)) => { - a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5 + (LCurve::Spline(a0, a1, a2, a3, a4, a5, a6), LCurve::Spline(b0, b1, b2, b3, b4, b5, b6)) => { + a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5 && a6 == b6 } _ => false, } @@ -539,26 +608,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 +637,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 { @@ -595,10 +645,33 @@ impl Sketch { let hi = ps.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y))); 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 loop_roles_required = self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) + && cur.iter().chain(&lay.curves).all(|c| matches!(c, LCurve::Line(..) | LCurve::Arc(..) | LCurve::Circle(..))) + && lay.lone.is_empty(); + let loop_roles = loop_roles_required.then(|| loops::roles(cur, &old, &lay.curves, &lay.pts)).flatten(); + if loop_roles_required && loop_roles.is_none() { + self.set_link_lost(id, true); + return Err(SketchError::Invalid("projected face boundary identity is ambiguous; last geometry retained".into())); + } + let (a, b) = match &loop_roles { + Some(roles) => (roles.old_normal.clone(), roles.fresh_normal.clone()), + None => (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) + && loop_roles.as_ref().is_none_or(|roles| { + roles.old.get(old_i) == roles.fresh.get(*i) + && roles.old_lines.get(old_i).zip(roles.fresh_lines.get(*i)).is_some_and(|(a, b)| loops::same_lines(a, b)) + }) + }) + .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 +686,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 +759,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 +871,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..bae16d88 --- /dev/null +++ b/crates/sketch/src/link_identity_tests.rs @@ -0,0 +1,333 @@ +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::>()); +} + +#[test] +fn nested_face_resize_matches_each_loop_before_nearby_outer_vertices() { + let rectangle = |x: f64, y: f64| { + let p = [Vec2::new(-x, -y), Vec2::new(x, -y), Vec2::new(x, y), Vec2::new(-x, y)]; + (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect::>() + }; + let mut sk = Sketch::new(); + let old: Vec<_> = rectangle(38.0, 28.0).into_iter().chain(rectangle(40.0, 30.0)).collect(); + let link = sk.add_link(LinkKind::Project, face(), &old).unwrap(); + let ids: Vec<_> = sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect(); + let points = sk.link_points(&link); + let before: Vec<_> = points.iter().map(|i| sk.points[*i].pos).collect(); + let fresh: Vec<_> = rectangle(41.0, 36.0).into_iter().chain(rectangle(38.0, 33.0)).collect(); + assert!(!sk.update_link(&link, &fresh).unwrap()); + assert!(!sk.link(&link).unwrap().lost); + for (i, p) in points.iter().zip(before) { + let expected = if p.x.abs() == 38.0 { Vec2::new(p.x, p.y.signum() * 33.0) } else { Vec2::new(p.x.signum() * 41.0, p.y.signum() * 36.0) }; + assert_eq!(sk.points[*i].pos, expected); + } + assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::>()); +} + +#[test] +fn growing_face_retains_stationary_hole_arcs_and_shared_centre() { + let geometry = |width: f64| { + let p = [Vec2::ZERO, Vec2::new(width, 0.0), Vec2::new(width, 30.0), Vec2::new(0.0, 30.0)]; + let mut out: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect(); + let c = Vec2::new(20.0, 15.0); + out.extend([ + LinkGeom::Arc { c, a: c + Vec2::X * 5.0, b: c - Vec2::X * 5.0 }, + LinkGeom::Arc { c, a: c - Vec2::X * 5.0, b: c + Vec2::X * 5.0 }, + ]); + out + }; + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, face(), &geometry(40.0)).unwrap(); + let saved = sk.clone(); + assert!(!sk.update_link(&link, &geometry(50.0)).unwrap()); + assert!(!sk.link(&link).unwrap().lost); + for i in sk.link_curves(&link) { + assert_eq!(sk.curves[i].id, saved.curves[i].id); + if matches!(sk.curves[i].kind, CurveKind::Arc { .. }) { + assert_eq!(sk.curves[i], saved.curves[i]); + for p in sk.curves[i].kind.point_ids() { + assert_eq!(sk.points[p], saved.points[p]); + } + } + } +} + +#[test] +fn touching_or_crossed_face_boundaries_refuse_without_retargeting_ids() { + for x in [9.0, 11.0] { + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::ZERO, 1.0)]).unwrap(); + let saved = sk.clone(); + assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(x, 0.0), 1.0)]).is_err()); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); + } +} + +#[test] +fn resized_rounded_rim_retains_inner_outer_roles_with_shared_arc_centres() { + let rounded = |x: f64, y: f64, r: f64| { + let centres = [Vec2::new(x - r, -y + r), Vec2::new(x - r, y - r), Vec2::new(-x + r, y - r), Vec2::new(-x + r, -y + r)]; + let mut out = Vec::new(); + for i in 0..4 { + let start = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::FRAC_PI_2; + let previous = centres[(i + 3) % 4] + Vec2::from_angle(start) * r; + let a = centres[i] + Vec2::from_angle(start) * r; + let b = centres[i] + Vec2::from_angle(start + std::f64::consts::FRAC_PI_2) * r; + out.push(LinkGeom::Line(previous, a)); + out.push(LinkGeom::Arc { c: centres[i], a, b }); + } + out + }; + let mut sk = Sketch::new(); + let old: Vec<_> = rounded(38.0, 28.0, 2.0).into_iter().chain(rounded(40.0, 30.0, 4.0)).collect(); + let link = sk.add_link(LinkKind::Project, face(), &old).unwrap(); + let curves = sk.link_curves(&link); + let ids: Vec<_> = curves.iter().map(|i| sk.curves[*i].id.clone()).collect(); + let fresh: Vec<_> = rounded(41.0, 36.0, 4.0).into_iter().chain(rounded(38.0, 33.0, 1.0)).collect(); + assert!(!sk.update_link(&link, &fresh).unwrap()); + assert!(!sk.link(&link).unwrap().lost); + assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::>()); + for (old, fresh) in curves.iter().zip(rounded(38.0, 33.0, 1.0).into_iter().chain(rounded(41.0, 36.0, 4.0))) { + match (&sk.curves[*old].kind, fresh) { + (CurveKind::Line { a, b }, LinkGeom::Line(p, q)) => { + assert!(sk.points[*a].pos.dist(p) < 1e-10); + assert!(sk.points[*b].pos.dist(q) < 1e-10); + } + (CurveKind::Arc { c, a, b }, LinkGeom::Arc { c: p, a: q, b: r }) => { + assert!(sk.points[*c].pos.dist(p) < 1e-10); + assert!(sk.points[*a].pos.dist(q) < 1e-10); + assert!(sk.points[*b].pos.dist(r) < 1e-10); + } + _ => panic!(), + } + } +} + +#[test] +fn far_translated_small_hole_crossing_long_edge_is_not_certified_separate() { + let mut sk = Sketch::new(); + let p = [Vec2::new(-1e8, 1.0), Vec2::new(1e8, 1.0), Vec2::new(1e8, 10.0), Vec2::new(-1e8, 10.0)]; + let mut old: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect(); + old.push(LinkGeom::Circle(Vec2::new(1e6, 5.0), 2.0)); + let link = sk.add_link(LinkKind::Project, face(), &old).unwrap(); + let saved = sk.clone(); + let mut fresh: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect(); + fresh.push(LinkGeom::Circle(Vec2::new(1e6, 0.0), 2.0)); + assert!(sk.update_link(&link, &fresh).is_err()); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); +} + +#[test] +fn equal_nested_sibling_candidates_remain_ambiguous_after_loop_classification() { + let mut sk = Sketch::new(); + let link = sk + .add_link( + LinkKind::Project, + face(), + &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(-2.0, 0.0), 1.0), LinkGeom::Circle(Vec2::new(2.0, 0.0), 1.0)], + ) + .unwrap(); + let saved = sk.clone(); + assert!( + sk.update_link( + &link, + &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(0.0, -2.0), 1.0), LinkGeom::Circle(Vec2::new(0.0, 2.0), 1.0)] + ) + .is_err() + ); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); +} + +#[test] +fn tiny_circle_crossing_huge_round_boundary_is_not_certified_separate() { + let mut sk = Sketch::new(); + let direction = Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2); + let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 1e8), LinkGeom::Circle(direction * (1e8 + 10.0), 2.0)]).unwrap(); + let saved = sk.clone(); + assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 1e8), LinkGeom::Circle(direction * (1e8 + 1.0), 2.0)]).is_err()); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); +} + +#[test] +fn huge_loop_does_not_make_nearby_bowtie_edges_topologically_adjacent() { + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, face(), &square(10.0)).unwrap(); + let saved = sk.clone(); + let p = [ + Vec2::ZERO, + Vec2::new(0.04, 0.04), + Vec2::new(0.0, 0.04), + Vec2::new(0.04, 0.0), + Vec2::new(9e8, 0.0), + Vec2::new(9e8, 10.0), + Vec2::new(0.0, 10.0), + ]; + let crossed: Vec<_> = (0..p.len()).map(|i| LinkGeom::Line(p[i], p[(i + 1) % p.len()])).collect(); + assert!(sk.update_link(&link, &crossed).is_err()); + assert_eq!(sk.points, saved.points); + assert_eq!(sk.curves, saved.curves); + assert!(sk.link(&link).unwrap().lost); +} diff --git a/crates/sketch/src/link_loops.rs b/crates/sketch/src/link_loops.rs new file mode 100644 index 00000000..72f557cf --- /dev/null +++ b/crates/sketch/src/link_loops.rs @@ -0,0 +1,343 @@ +//! Conservative analytic boundary roles for associative face identity. +//! Endpoint walks exclude support centres. Crossings/touches refuse before nesting. +use super::{LCurve, curve_points}; +use crate::{Shape, intersections}; +use solvecraft_geom::Vec2; +use std::f64::consts::TAU; + +struct Boundary { + curves: Vec, + shapes: Vec, + lo: Vec2, + hi: Vec2, + depth: usize, + signature: [usize; 3], +} +pub(super) struct Roles { + pub old: Vec>, + pub fresh: Vec>, + pub old_normal: Vec, + pub fresh_normal: Vec, + pub old_lines: Vec>, + pub fresh_lines: Vec>, +} + +pub(super) fn same_lines(a: &[Vec2], b: &[Vec2]) -> bool { + a.len() == b.len() && a.iter().all(|x| b.iter().any(|y| x.dot(*y).abs() > 0.98)) +} +fn on(s: &Shape, p: Vec2, tol: f64) -> bool { + match *s { + Shape::Line { a, b } => { + let d = b - a; + let t = (p - a).dot(d) / d.len2().max(1e-30); + t >= -tol / d.len().max(tol) && t <= 1.0 + tol / d.len().max(tol) && (a + d * t).dist(p) <= tol + } + Shape::Round { c, r, start, sweep } => { + (p.dist(c) - r).abs() <= tol && (sweep >= TAU - 1e-12 || ((p - c).angle() - start).rem_euclid(TAU) <= sweep + tol / r) + } + } +} +fn endpoints(s: &Shape) -> [Vec2; 2] { + let (_, end) = s.range(); + [s.point(0.0), s.point(end)] +} +fn stable_intersections(a: &Shape, b: &Shape, tol: f64) -> Vec { + match (*a, *b) { + (Shape::Line { a, b }, Shape::Round { c, r, .. }) | (Shape::Round { c, r, .. }, Shape::Line { a, b }) => { + let Some(direction) = (b - a).normalized() else { return Vec::new() }; + // Work in the circle's local orthonormal frame. The world-coordinate + // quadratic discriminant can lose every significant bit on long lines. + let foot = a + direction * (c - a).dot(direction); + let distance = foot.dist(c); + if distance > r + tol { + return Vec::new(); + } + let half = ((r - distance).max(0.0) * (r + distance)).sqrt(); + vec![foot - direction * half, foot + direction * half] + } + (Shape::Round { c: ac, r: ar, .. }, Shape::Round { c: bc, r: br, .. }) => { + let ((small, r), (large, radius)) = if ar <= br { ((ac, ar), (bc, br)) } else { ((bc, br), (ac, ar)) }; + let delta = large - small; + let distance = delta.len(); + if distance <= tol || distance > radius + r + tol || distance < radius - r - tol { + return Vec::new(); + } + let direction = delta / distance; + // Compute from the smaller circle; factoring d^2-R^2 avoids the + // cancellation of R^2-a^2 for a small hole crossing a huge circle. + let along = ((distance - radius) * (distance + radius) + r * r) / (2.0 * distance); + let height = ((r - along).max(0.0) * (r + along).max(0.0)).sqrt(); + let foot = small + direction * along; + vec![foot - direction.perp() * height, foot + direction.perp() * height] + } + _ => intersections(a, b), + } +} +fn clear(a: &Shape, b: &Shape, shared: &[Vec2], tol: f64) -> bool { + let allowed = |p: Vec2| { + shared.iter().any(|q| { + let joint_tol = 1e-7 + 128.0 * f64::EPSILON * (1.0 + p.len().max(q.len())); + q.dist(p) <= joint_tol + }) + }; + // Parallel coincident lines/circles have no isolated intersections. + // Interior overlap is nevertheless ambiguous, including duplicate boundaries. + for s in [a, b] { + let other = if std::ptr::eq(s, a) { b } else { a }; + let (_, end) = s.range(); + for t in [0.0, 0.5 * end, end] { + let p = s.point(t); + if on(other, p, tol) && !allowed(p) { + return false; + } + } + } + stable_intersections(a, b, tol).into_iter().all(|p| !on(a, p, tol) || !on(b, p, tol) || allowed(p)) +} +fn contains(boundary: &Boundary, p: Vec2, tol: f64) -> Option { + let mut crossings = 0; + for shape in &boundary.shapes { + // Avoid ray/vertex coincidences rather than guessing a half-open arc rule. + if endpoints(shape).iter().any(|q| (q.y - p.y).abs() <= tol) { + return None; + } + match *shape { + Shape::Line { a, b } => { + if (a.y > p.y) != (b.y > p.y) { + let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y); + if (x - p.x).abs() <= tol { + return None; + } + crossings += usize::from(x > p.x); + } + } + Shape::Round { c, r, start, sweep } => { + let y = (p.y - c.y) / r; + if (y.abs() - 1.0).abs() <= tol / r { + return None; + } + if y.abs() < 1.0 { + let angle = y.asin(); + for theta in [angle, std::f64::consts::PI - angle] { + let off = (theta - start).rem_euclid(TAU); + if off < sweep { + let x = c.x + r * theta.cos(); + if (x - p.x).abs() <= tol { + return None; + } + crossings += usize::from(x > p.x); + } + } + } + } + } + } + Some(crossings % 2 == 1) +} +fn boundaries(curves: &[LCurve], pts: &[Vec2]) -> Option> { + let get = |i| pts.get(i).copied(); + let mut shapes = Vec::new(); + let mut ends = Vec::new(); + let mut incidence = vec![Vec::new(); pts.len()]; + for (i, curve) in curves.iter().enumerate() { + let (shape, ep) = match *curve { + LCurve::Line(a, b) => (Shape::Line { a: get(a)?, b: get(b)? }, Some((a, b))), + LCurve::Arc(c, a, b) => { + let (c, a_pos, b_pos) = (get(c)?, get(a)?, get(b)?); + let sweep = ((b_pos - c).angle() - (a_pos - c).angle()).rem_euclid(TAU); + (Shape::Round { c, r: c.dist(a_pos), start: (a_pos - c).angle(), sweep }, Some((a, b))) + } + LCurve::Circle(c, r) => (Shape::Round { c: get(c)?, r, start: 0.0, sweep: TAU }, None), + // Existing free-form identity matching remains conservative and unchanged. + _ => return None, + }; + if let Some((a, b)) = ep { + incidence.get_mut(a)?.push(i); + incidence.get_mut(b)?.push(i); + } + shapes.push(shape); + ends.push(ep); + } + if incidence.iter().any(|v| !v.is_empty() && v.len() != 2) { + return None; + } + let mut visited = vec![false; curves.len()]; + let mut out = Vec::new(); + for first in 0..curves.len() { + if *visited.get(first)? { + continue; + } + let mut members = Vec::new(); + let mut pending = vec![first]; + while let Some(i) = pending.pop() { + if *visited.get(i)? { + continue; + } + *visited.get_mut(i)? = true; + members.push(i); + if let Some((a, b)) = *ends.get(i)? { + pending.extend(incidence.get(a)?); + pending.extend(incidence.get(b)?); + } + } + let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY); + let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY); + let mut signature = [0; 3]; + let mut bound_shapes = Vec::new(); + for i in &members { + let shape = *shapes.get(*i)?; + let mut candidates = endpoints(&shape).to_vec(); + if let Shape::Round { c, r, .. } = shape { + for angle in [0.0, std::f64::consts::FRAC_PI_2, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2] { + let p = c + Vec2::from_angle(angle) * r; + if on(&shape, p, 1e-7) { + candidates.push(p); + } + } + } + for p in candidates { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + signature[match curves.get(*i)? { + LCurve::Line(..) => 0, + LCurve::Arc(..) => 1, + _ => 2, + }] += 1; + bound_shapes.push(shape); + } + out.push(Boundary { curves: members, shapes: bound_shapes, lo, hi, depth: 0, signature }); + } + let scale = out.iter().map(|b| (b.hi - b.lo).len()).fold(1.0, f64::max); + let tol = (scale * 1e-10).max(1e-7); + let mut work = 0usize; + for i in 0..out.len() { + for j in i..out.len() { + for (a_i, a) in out.get(i)?.shapes.iter().enumerate() { + for (b_i, b) in out.get(j)?.shapes.iter().enumerate() { + if i == j && a_i >= b_i { + continue; + } + work += 1; + if work > 262_144 { + return None; + } + let mut shared = Vec::new(); + if i == j { + let ai = *out.get(i)?.curves.get(a_i)?; + let bi = *out.get(j)?.curves.get(b_i)?; + if let (Some((a0, a1)), Some((b0, b1))) = (*ends.get(ai)?, *ends.get(bi)?) { + for point in [a0, a1] { + if point == b0 || point == b1 { + shared.push(get(point)?); + } + } + } + } + if !clear(a, b, &shared, tol) { + return None; + } + } + } + } + } + for i in 0..out.len() { + let mut depth = 0; + for j in 0..out.len() { + if i == j { + continue; + } + let shape = out.get(i)?.shapes.first()?; + let (_, end) = shape.range(); + let inside = [0.317, 0.613, 0.823].into_iter().find_map(|t| contains(out.get(j)?, shape.point(end * t), tol))?; + depth += usize::from(inside); + } + out.get_mut(i)?.depth = depth; + } + Some(out) +} + +pub(super) fn valid(curves: &[LCurve], points: &[Vec2]) -> bool { + boundaries(curves, points).is_some() +} +fn normalize(p: Vec2, lo: Vec2, hi: Vec2) -> Vec2 { + Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9)) +} +pub(super) fn roles(old_curves: &[LCurve], a: &[Vec2], fresh_curves: &[LCurve], b: &[Vec2]) -> Option { + let (previous, incoming) = (boundaries(old_curves, a)?, boundaries(fresh_curves, b)?); + if previous.len() != incoming.len() { + return None; + } + let bounds = |loops: &[Boundary]| { + loops.iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), x| { + (Vec2::new(lo.x.min(x.lo.x), lo.y.min(x.lo.y)), Vec2::new(hi.x.max(x.hi.x), hi.y.max(x.hi.y))) + }) + }; + let ((alo, ahi), (blo, bhi)) = (bounds(&previous), bounds(&incoming)); + let mut mapping = Vec::new(); + for x in &previous { + let mut candidates: Vec<_> = incoming + .iter() + .enumerate() + .filter(|(_, y)| x.depth == y.depth && x.signature == y.signature) + .map(|(i, y)| (i, normalize((x.lo + x.hi) * 0.5, alo, ahi).dist(normalize((y.lo + y.hi) * 0.5, blo, bhi)))) + .collect(); + candidates.sort_by(|x, y| x.1.total_cmp(&y.1)); + let (i, d) = *candidates.first()?; + if mapping.contains(&i) || (candidates.len() > 1 && (d >= 0.1 || candidates.get(1).is_some_and(|(_, second)| d >= second * 0.5))) { + return None; + } + let y = incoming.get(i)?; + // Preserve the conservative bound on relative boundary changes. This does + // not mistake a stationary hole's coordinates for the outer loop's scaling. + let old_size = normalize(x.hi, alo, ahi) - normalize(x.lo, alo, ahi); + let new_size = normalize(y.hi, blo, bhi) - normalize(y.lo, blo, bhi); + if old_size.dist(new_size) >= 0.1 { + return None; + } + mapping.push(i); + } + let membership = |loops: &[Boundary], curves: &[LCurve], pts: &[Vec2], remap: Option<&[usize]>| -> Option<(Vec>, Vec)> { + let mut roles = vec![Vec::new(); pts.len()]; + let mut coordinates = vec![Vec2::ZERO; pts.len()]; + for (i, loop_) in loops.iter().enumerate() { + let target = remap.and_then(|m| m.get(i)).copied().unwrap_or(i); + for curve in &loop_.curves { + for p in curve_points(curves.get(*curve)?) { + if !roles.get(p)?.contains(&target) { + roles.get_mut(p)?.push(target); + *coordinates.get_mut(p)? += normalize(*pts.get(p)?, loop_.lo, loop_.hi); + } + } + } + } + for (role, coordinate) in roles.iter_mut().zip(&mut coordinates) { + if role.is_empty() { + return None; + } + role.sort_unstable(); + *coordinate = *coordinate / role.len() as f64; + } + Some((roles, coordinates)) + }; + let ((old, old_normal), (fresh, fresh_normal)) = + (membership(&previous, old_curves, a, Some(&mapping))?, membership(&incoming, fresh_curves, b, None)?); + let line_roles = |loops: &[Boundary], curves: &[LCurve], pts: &[Vec2]| -> Option>> { + let mut roles = vec![Vec::new(); pts.len()]; + for loop_ in loops { + let size = loop_.hi - loop_.lo; + for i in &loop_.curves { + if let LCurve::Line(a, b) = *curves.get(*i)? { + let d = *pts.get(b)? - *pts.get(a)?; + let direction = Vec2::new(d.x / size.x.max(1e-9), d.y / size.y.max(1e-9)).normalized()?; + roles.get_mut(a)?.push(direction); + roles.get_mut(b)?.push(direction); + } + } + } + Some(roles) + }; + let old_lines = line_roles(&previous, old_curves, a)?; + let fresh_lines = line_roles(&incoming, fresh_curves, b)?; + Some(Roles { old, fresh, old_normal, fresh_normal, old_lines, fresh_lines }) +} diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 2b5a0079..35d80e35 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -53,6 +53,9 @@ pub enum CurveKind { control: bool, #[serde(default = "three")] degree: u8, + /// Explicit cyclic C2 fit interpolation; absent retains natural endpoints. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + periodic: bool, }, /// Conic from `a` to `b` shaped by the `apex` point and `rho` in (0, 1) (0.5 parabola, /// less elliptical, more hyperbolic). @@ -111,6 +114,7 @@ impl CurveKind { pub fn ends(&self) -> Option<(usize, usize)> { match self { CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } => Some((*a, *b)), + CurveKind::Spline { periodic: true, .. } => None, CurveKind::Spline { pts, .. } => Some((*pts.first()?, *pts.last()?)), CurveKind::Circle { .. } | CurveKind::Ellipse { .. } => None, } @@ -233,6 +237,22 @@ pub enum ConstraintKind { l: usize, value: f64, }, + /// Signed angle of a line's stored start-to-end direction from sketch +X (radians). + LineAngle { + l: usize, + value: f64, + }, + /// Signed logical sweep of an arc (radians), preserving clockwise and major arcs. + ArcSweep { + c: usize, + value: f64, + }, + /// Overall length of a straight slot: cap-centre distance plus both cap radii. + SlotOverallLength { + a: usize, + b: usize, + value: f64, + }, Radius { c: usize, value: f64, @@ -282,6 +302,9 @@ impl ConstraintKind { | DistanceY { value, .. } | PointLineDistance { value, .. } | Length { value, .. } + | LineAngle { value, .. } + | ArcSweep { value, .. } + | SlotOverallLength { value, .. } | Radius { value, .. } | Diameter { value, .. } | ArcLength { value, .. } @@ -298,6 +321,9 @@ impl ConstraintKind { | DistanceY { value, .. } | PointLineDistance { value, .. } | Length { value, .. } + | LineAngle { value, .. } + | ArcSweep { value, .. } + | SlotOverallLength { value, .. } | Radius { value, .. } | Diameter { value, .. } | ArcLength { value, .. } @@ -307,7 +333,7 @@ impl ConstraintKind { } } pub fn is_angle(&self) -> bool { - matches!(self, ConstraintKind::Angle { .. }) + matches!(self, ConstraintKind::Angle { .. } | ConstraintKind::LineAngle { .. } | ConstraintKind::ArcSweep { .. }) } /// Short type name (Fusion-like) for listings. pub fn name(&self) -> &'static str { @@ -328,7 +354,8 @@ impl ConstraintKind { Distance { .. } | DistanceX { .. } | DistanceY { .. } | PointLineDistance { .. } | Length { .. } => "LinearDimension", Radius { .. } => "RadialDimension", Diameter { .. } => "DiameterDimension", - Angle { .. } => "AngularDimension", + Angle { .. } | LineAngle { .. } | ArcSweep { .. } => "AngularDimension", + SlotOverallLength { .. } => "LinearDimension", Smooth { .. } => "Smooth", ArcLength { .. } => "ArcLengthDimension", LinearDiameter { .. } => "LinearDiameterDimension", @@ -340,12 +367,12 @@ impl ConstraintKind { Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]), Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => (vec![p, q], vec![]), PointOnCurve { p, c } => (vec![p], vec![c]), - Horizontal { l } | Vertical { l } | Length { l, .. } => (vec![], vec![l]), - Radius { c, .. } | Diameter { c, .. } => (vec![], vec![c]), + Horizontal { l } | Vertical { l } | Length { l, .. } | LineAngle { l, .. } => (vec![], vec![l]), + Radius { c, .. } | Diameter { c, .. } | ArcSweep { c, .. } => (vec![], vec![c]), Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } => { (vec![], vec![a, b]) } - Angle { a, b, .. } => (vec![], vec![a, b]), + Angle { a, b, .. } | SlotOverallLength { a, b, .. } => (vec![], vec![a, b]), Midpoint { p, l } | PointLineDistance { p, l, .. } => (vec![p], vec![l]), Symmetric { p, q, l } => (vec![p, q], vec![l]), Fix { p } => (vec![p], vec![]), @@ -379,13 +406,13 @@ impl ConstraintKind { fp(p); fc(c); } - Horizontal { l } | Vertical { l } | Length { l, .. } => fc(l), - Radius { c, .. } | Diameter { c, .. } => fc(c), + Horizontal { l } | Vertical { l } | Length { l, .. } | LineAngle { l, .. } => fc(l), + Radius { c, .. } | Diameter { c, .. } | ArcSweep { c, .. } => fc(c), Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } => { fc(a); fc(b); } - Angle { a, b, .. } => { + Angle { a, b, .. } | SlotOverallLength { a, b, .. } => { fc(a); fc(b); } @@ -469,6 +496,9 @@ pub struct Sketch { pub links: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub offsets: Vec, + /// New certified normal offsets; legacy sampled output splines remain independent. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub freeform_offsets: Vec, /// 3D curves (world coordinates) carried by the sketch. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub wires: Vec, @@ -498,6 +528,7 @@ impl Sketch { constraints: Vec::new(), links: Vec::new(), offsets: Vec::new(), + freeform_offsets: Vec::new(), wires: Vec::new(), counters: Default::default(), view: SketchView::default(), @@ -694,11 +725,12 @@ impl Sketch { Horizontal { l } | Vertical { l } | Length { l, .. } + | LineAngle { l, .. } | Midpoint { l, .. } | Symmetric { l, .. } | PointLineDistance { l, .. } | LinearDiameter { l, .. } => need_line(l)?, - ArcLength { c, .. } if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) => { + ArcLength { c, .. } | ArcSweep { c, .. } if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) => { return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into())); } Smooth { a, b } => { @@ -722,6 +754,36 @@ impl Sketch { need_round(a)?; need_round(b)?; } + SlotOverallLength { a, b, value } => { + if a == b || ![a, b].iter().all(|i| matches!(self.curves.get(*i).map(|c| &c.kind), Some(CurveKind::Arc { .. }))) { + return Err(SketchError::Invalid("overall slot length needs two different arc caps".into())); + } + let radius = |cap: usize| { + self.constraints + .iter() + .filter(|c| !c.driven) + .find_map(|dimension| match dimension.kind { + Radius { c, value } if c == cap => Some(value), + Diameter { c, value } if c == cap => Some(value / 2.0), + // Slot caps are constrained equal; a diameter on either drives both. + Diameter { c, value } | Radius { c, value } + if (c == a || c == b) + && self + .constraints + .iter() + .any(|e| matches!(e.kind, Equal { a: x, b: y } if (x == a && y == b) || (x == b && y == a))) => + { + Some(if matches!(dimension.kind, Diameter { .. }) { value / 2.0 } else { value }) + } + _ => None, + }) + .or_else(|| self.radius(cap)) + .unwrap_or(0.0) + }; + if value <= radius(a) + radius(b) + 1e-9 { + return Err(SketchError::Invalid("overall slot length must exceed its width".into())); + } + } Tangent { a, b } => { let free = |i: usize| self.curves.get(i).is_some_and(|c| c.kind.is_freeform()); if !(is_round(a) || is_round(b) || free(a) || free(b)) { @@ -739,6 +801,11 @@ impl Sketch { { return Err(SketchError::Invalid(format!("dimension value {v}"))); } + if let ArcSweep { value, .. } = *k + && !(value.abs() > 1e-9 && value.abs() < std::f64::consts::TAU - 1e-9) + { + return Err(SketchError::Invalid("arc sweep must be nonzero and less than a full turn".into())); + } Ok(()) } @@ -827,7 +894,7 @@ impl Sketch { /// Is curve `c` held in place: fixed or projected (linked). pub fn curve_locked(&self, c: usize) -> bool { - self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some()) + self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id)) } /// Radius of a circle or arc. @@ -899,11 +966,13 @@ impl Sketch { ) } CurveKind::Conic { a, b, apex, rho } => Some(vec![Seg2::Conic { a: pt(a)?, apex: pt(apex)?, b: pt(b)?, w: rho / (1.0 - rho).max(1e-9) }]), - CurveKind::Spline { pts, control: false, .. } => { + CurveKind::Spline { pts, control: false, periodic, .. } => { let p: Vec = pts.iter().map(pt).collect::>()?; - Some(crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect()) + Some( + crate::curves::fit_beziers_mode(&p, *periodic).ok()?.into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect(), + ) } - CurveKind::Spline { pts, control: true, degree } if (*degree as usize) <= solvecraft_geom::MAX_BEZIER_DEGREE => { + CurveKind::Spline { pts, control: true, degree, .. } if (*degree as usize) <= solvecraft_geom::MAX_BEZIER_DEGREE => { let p: Vec = pts.iter().map(pt).collect::>()?; let k = (*degree as usize).min(p.len().saturating_sub(1)).max(1); Some(crate::curves::bspline_beziers(&p, k).iter().filter_map(|c| solvecraft_geom::bezier(c)).collect()) @@ -922,9 +991,9 @@ impl Sketch { (Some(c), Some(m)) => crate::curves::ellipse_polyline(c, m, *r), _ => Vec::new(), }, - CurveKind::Spline { pts, control, degree } => { + CurveKind::Spline { pts, control, degree, periodic } => { let p: Option> = pts.iter().map(pt).collect(); - p.map(|p| crate::curves::spline_polyline(&p, *control, *degree)).unwrap_or_default() + p.map(|p| crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic)).unwrap_or_default() } CurveKind::Conic { a, b, apex, rho } => match (pt(a), pt(apex), pt(b)) { (Some(a), Some(x), Some(b)) => crate::curves::conic_polyline(a, x, b, *rho), @@ -960,10 +1029,20 @@ impl Sketch { } "e" } - CurveKind::Spline { pts, degree, .. } => { + CurveKind::Spline { pts, control, degree, periodic } => { if pts.len() < 2 || pts.len() > crate::curves::MAX_SPLINE_POINTS || !(1..=7).contains(degree) { return Err(SketchError::Invalid("a spline needs 2 to 500 points and degree 1 to 7".into())); } + if *periodic { + if *control || *degree != 3 { + return Err(SketchError::Invalid("periodic mode requires a cubic fit spline".into())); + } + let p = pts + .iter() + .map(|i| self.point(*i).ok_or_else(|| SketchError::Invalid("missing periodic point".into()))) + .collect::>>()?; + crate::periodic_beziers(&p)?; + } "s" } CurveKind::Conic { rho, .. } => { @@ -1001,6 +1080,7 @@ impl Sketch { /// Remove curves only (points stay), remapping constraints. fn drop_curves(&mut self, which: &[usize]) { + self.prune_offsets_for_removed_curves(which); let mut cmap = vec![None; self.curves.len()]; let mut n = 0; for (i, slot) in cmap.iter_mut().enumerate() { @@ -1028,7 +1108,34 @@ impl Sketch { let gone = |p: usize| p != 0 && which.contains(&p); let dead_curves: Vec = self.curves.iter().enumerate().filter(|(_, c)| c.kind.point_ids().into_iter().any(gone)).map(|(i, _)| i).collect(); + let private = dead_curves + .iter() + .filter_map(|i| self.curves.get(*i)) + .filter(|c| self.freeform_offsets.iter().any(|r| r.output == c.id)) + .flat_map(|c| c.kind.point_ids()) + .collect::>(); self.drop_curves(&dead_curves); + let mut removed = which.iter().copied().filter(|i| *i != 0).collect::>(); + // Keep any node still named by a surviving curve or constraint. + let referenced = self.curves.iter().flat_map(|c| c.kind.point_ids()).collect::>(); + let constraint_points = std::cell::RefCell::new(std::collections::BTreeSet::new()); + for constraint in &self.constraints { + let mut kind = constraint.kind.clone(); + kind.remap( + &|p| { + constraint_points.borrow_mut().insert(p); + Some(p) + }, + &Some, + ); + } + let constraint_points = constraint_points.into_inner(); + for index in private { + if index != 0 && !referenced.contains(&index) && !constraint_points.contains(&index) { + removed.insert(index); + } + } + let gone = |p: usize| removed.contains(&p); let mut pmap = vec![None; self.points.len()]; let mut n = 0; for (i, slot) in pmap.iter_mut().enumerate() { diff --git a/crates/sketch/src/offset.rs b/crates/sketch/src/offset.rs index b3f06455..cca8ec3e 100644 --- a/crates/sketch/src/offset.rs +++ b/crates/sketch/src/offset.rs @@ -12,7 +12,51 @@ pub struct OffsetChain { } impl Sketch { + /// Removing an output removes its relationship. A partial analytic chain + /// deletion releases the surviving outputs, while retained dependents keep + /// their source references so the command's refresh refuses broken sources. + pub(crate) fn prune_offsets_for_removed_curves(&mut self, which: &[usize]) { + let removed = which.iter().filter_map(|i| self.curves.get(*i).map(|curve| curve.id.clone())).collect::>(); + if removed.is_empty() { + return; + } + let released = self + .offsets + .iter() + .filter(|offset| offset.outputs.iter().any(|id| removed.contains(id))) + .flat_map(|offset| offset.outputs.iter().cloned()) + .chain(self.freeform_offsets.iter().filter(|offset| removed.contains(&offset.output)).map(|offset| offset.output.clone())) + .collect::>(); + self.offsets.retain(|offset| !offset.outputs.iter().any(|id| removed.contains(id))); + self.freeform_offsets.retain(|offset| !removed.contains(&offset.output)); + let points = self + .curves + .iter() + .filter(|curve| released.contains(&curve.id)) + .flat_map(|curve| curve.kind.point_ids()) + .collect::>(); + for index in points { + if index != 0 + && self.curves.iter().all(|curve| !curve.kind.uses(index) || released.contains(&curve.id)) + && let Some(point) = self.points.get_mut(index) + { + point.fixed = false; + } + } + } + pub fn refresh_offsets(&mut self) -> Result<(), SketchError> { + if self.offsets.is_empty() && self.freeform_offsets.is_empty() { + return Ok(()); + } + let mut candidate = self.clone(); + candidate.refresh_analytic_offsets()?; + candidate.refresh_freeform_offsets()?; + *self = candidate; + Ok(()) + } + + fn refresh_analytic_offsets(&mut self) -> Result<(), SketchError> { for offset in self.offsets.clone() { if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() { return Err(SketchError::Invalid("damaged offset chain".into())); diff --git a/crates/sketch/src/periodic.rs b/crates/sketch/src/periodic.rs new file mode 100644 index 00000000..b9e408e6 --- /dev/null +++ b/crates/sketch/src/periodic.rs @@ -0,0 +1,204 @@ +//! Explicit cyclic C2 fit splines. Missing periodic markers retain natural interpolation. +use crate::{MAX_SPLINE_POINTS, Sketch, SketchError}; +use solvecraft_geom::Vec2; + +type Result = std::result::Result; +fn bad() -> SketchError { + SketchError::Invalid("periodic fit spline needs 3 distinct cyclic samples, an exact closing point, and regular bounded chords".into()) +} + +pub(crate) fn validate_periodic_points(points: &[Vec2]) -> Result<()> { + if points.len() < 4 + || points.len() > MAX_SPLINE_POINTS + || points.first() != points.last() + || points.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) + || points.windows(2).any(|p| { + let h = p[0].dist(p[1]); + !h.is_finite() || h <= 1e-9 + }) + { + return Err(bad()); + } + let mut distinct = Vec::new(); + for p in points { + if !distinct.contains(p) { + distinct.push(*p); + } + if distinct.len() == 3 { + return Ok(()); + } + } + Err(bad()) +} +pub fn periodic_beziers(points: &[Vec2]) -> Result> { + validate_periodic_points(points)?; + let n = points.len() - 1; + let h: Vec = points.windows(2).map(|p| p[0].dist(p[1])).collect(); + let mut diagonal = Vec::with_capacity(n); + let mut rhs = Vec::with_capacity(n); + for i in 0..n { + let previous = (i + n - 1) % n; + diagonal.push(2.0 * (h[previous] + h[i])); + rhs.push(((points[i + 1] - points[i]) / h[i] - (points[i] - points[previous]) / h[previous]) * 6.0); + } + // Remove the two corner entries with a rank-one correction. The underlying + // cyclic matrix is strictly diagonally dominant; all pivots are still checked. + let corner = h[n - 1]; + let gamma = -diagonal[0]; + diagonal[0] -= gamma; + diagonal[n - 1] -= corner * corner / gamma; + let solve = |mut rhs: Vec| -> Result> { + let mut diagonal = diagonal.clone(); + for i in 1..n { + if !diagonal[i - 1].is_finite() || diagonal[i - 1].abs() < 1e-12 * (h[i - 1] + h[(i + n - 2) % n]) { + return Err(bad()); + } + let factor = h[i - 1] / diagonal[i - 1]; + diagonal[i] -= factor * h[i - 1]; + rhs[i] = rhs[i] - rhs[i - 1] * factor; + } + for i in (0..n).rev() { + if !diagonal[i].is_finite() || diagonal[i].abs() < 1e-12 * (h[i] + h[(i + n - 1) % n]) { + return Err(bad()); + } + let next = rhs.get(i + 1).copied().unwrap_or_default() * h[i]; + rhs[i] = (rhs[i] - next) / diagonal[i]; + if !rhs[i].is_finite() { + return Err(bad()); + } + } + Ok(rhs) + }; + let mut second = solve(rhs)?; + let mut correction_rhs = vec![Vec2::ZERO; n]; + correction_rhs[0].x = gamma; + correction_rhs[n - 1].x = corner; + let correction = solve(correction_rhs)?; + let denominator = 1.0 + correction[0].x + corner * correction[n - 1].x / gamma; + if !denominator.is_finite() || denominator.abs() <= 1e-12 { + return Err(bad()); + } + let factor = (second[0] + second[n - 1] * (corner / gamma)) / denominator; + for (value, correction) in second.iter_mut().zip(&correction) { + *value -= factor * correction.x; + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let next = (i + 1) % n; + let chord = points[i + 1] - points[i]; + let t0 = chord - (second[i] * 2.0 + second[next]) * (h[i] * h[i] / 6.0); + let t1 = chord + (second[i] + second[next] * 2.0) * (h[i] * h[i] / 6.0); + let cubic = [points[i], points[i] + t0 / 3.0, points[i + 1] - t1 / 3.0, points[i + 1]]; + if cubic.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) { + return Err(bad()); + } + out.push(cubic); + } + Ok(out) +} + +impl Sketch { + pub fn validate_periodic_splines(&self) -> Result<()> { + let mut work = 0usize; + for curve in &self.curves { + if let crate::CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind { + work = work.saturating_add(pts.len()); + if *control || *degree != 3 || work > 1_000_000 || pts.len() > MAX_SPLINE_POINTS { + return Err(bad()); + } + let points = pts.iter().map(|i| self.point(*i).ok_or_else(bad)).collect::>>()?; + validate_periodic_points(&points)?; + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + #[test] + fn periodic_c2_unequal_chords_and_legacy_natural_are_distinct() { + let p = [Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(3.0, 2.0), Vec2::new(-1.0, 1.0), Vec2::new(0.0, 0.0)]; + let curves = periodic_beziers(&p).unwrap(); + for i in 0..curves.len() { + let j = (i + 1) % curves.len(); + let (a, b) = (curves[i], curves[j]); + let (ha, hb) = (p[i].dist(p[i + 1]), p[j].dist(p[j + 1])); + assert_eq!(a[3], b[0]); + assert!(((a[3] - a[2]) * (3.0 / ha) - (b[1] - b[0]) * (3.0 / hb)).len() < 1e-12); + assert!(((a[3] - a[2] * 2.0 + a[1]) * (6.0 / (ha * ha)) - (b[2] - b[1] * 2.0 + b[0]) * (6.0 / (hb * hb))).len() < 1e-12); + } + let legacy = crate::fit_beziers(&p); + assert!( + (legacy[0][1] - legacy[0][0]).normalized().unwrap().dist((legacy.last().unwrap()[3] - legacy.last().unwrap()[2]).normalized().unwrap()) + > 0.01 + ); + let global = |t| crate::spline_point_mode(&p, false, 3, true, t); + let h = 1e-5; + assert!(((global(h) - global(0.0)) / h - (global(1.0) - global(1.0 - h)) / h).len() < 0.002); + } + #[test] + fn periodic_rejects_two_samples_and_hostile_geometry() { + let a = Vec2::ZERO; + let b = Vec2::X; + assert!(periodic_beziers(&[a, b, a, b, a]).is_err()); + assert!(periodic_beziers(&[a, b, Vec2::new(0.0, 1.0), b]).is_err()); + assert!(periodic_beziers(&[a, b, Vec2::new(f64::INFINITY, 1.0), a]).is_err()); + assert!(periodic_beziers(&vec![a; MAX_SPLINE_POINTS + 1]).is_err()); + } + + #[test] + fn periodic_solver_closes_shared_roles_and_refuses_immutable_or_collapsed_nodes() { + let mut sk = Sketch::new(); + let first = sk.add_point(Vec2::ZERO, None).unwrap(); + let a = sk.add_point(Vec2::new(4.0, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(3.0, 2.0), None).unwrap(); + let seam = sk.add_point(Vec2::ZERO, None).unwrap(); + let c = sk.add_point(Vec2::new(-4.0, 0.0), None).unwrap(); + let d = sk.add_point(Vec2::new(-3.0, -2.0), None).unwrap(); + let last = sk.add_point(Vec2::ZERO, None).unwrap(); + sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true }, None).unwrap(); + sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true }, None).unwrap(); + sk.points[first].pos = Vec2::new(0.2, 0.1); + assert!(crate::solve_holding(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }).ok()); + assert_eq!(sk.point(first), sk.point(seam)); + assert_eq!(sk.point(first), sk.point(last)); + sk.validate_periodic_splines().unwrap(); + let mut fixed = sk.clone(); + fixed.points[first].fixed = true; + fixed.points[seam].fixed = true; + fixed.points[seam].pos.x += 1e-8; + let before = fixed.clone(); + assert!(!crate::solve(&mut fixed).ok()); + assert_eq!(fixed, before); + let mut collapsed = sk; + collapsed.points[a].pos = collapsed.points[first].pos; + let before = collapsed.clone(); + assert!(!crate::solve_holding(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }).ok()); + assert_eq!(collapsed, before); + } + + #[test] + fn periodic_link_projection_keeps_mode_and_break_geometry() { + let p = vec![Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(3.0, 2.0), Vec2::new(-1.0, 1.0), Vec2::ZERO]; + let mut sk = Sketch::new(); + let source = crate::LinkSource::SketchCurve { sketch: 99, curve: "source".into() }; + let geometry = crate::LinkGeom::Spline { pts: p.clone(), control: false, degree: 3, periodic: true }; + let id = sk.add_link(crate::LinkKind::Project, source, &[geometry]).unwrap(); + let curve = sk.link_curves(&id)[0]; + assert!(matches!(sk.curves[curve].kind, crate::CurveKind::Spline { periodic: true, .. })); + let before = sk.exact_segs(curve).unwrap(); + sk.break_link(&id).unwrap(); + assert_eq!(sk.exact_segs(curve).unwrap(), before); + sk.validate_periodic_splines().unwrap(); + let mut singular = Sketch::new(); + let points = + [Vec2::ZERO, Vec2::X, Vec2::ZERO, Vec2::new(0.0, 1.0), Vec2::ZERO].into_iter().map(|p| singular.add_point(p, None).unwrap()).collect(); + let curve = singular.add_curve(crate::CurveKind::Spline { pts: points, control: false, degree: 3, periodic: true }, None).unwrap(); + let before = singular.clone(); + assert!(singular.add_freeform_offset(curve, true, 0.1).is_err()); + assert_eq!(singular, before); + } +} diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index c703e2e6..96e9c722 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}; @@ -169,14 +169,73 @@ impl State<'_> { }; crate::curves::ellipse_polyline(self.p(*ci), self.p(*m), r) } - Some(CurveKind::Spline { pts, control, degree }) => { - let p: Vec = pts.iter().map(|q| self.p(*q)).collect(); - crate::curves::spline_polyline(&p, *control, *degree) + Some(CurveKind::Spline { pts, control, degree, periodic }) => { + let p = self.spline_points(pts, *periodic); + crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic) } Some(CurveKind::Conic { a, b, apex, rho }) => crate::curves::conic_polyline(self.p(*a), self.p(*apex), self.p(*b), *rho), _ => 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 spline_points(&self, pts: &[usize], periodic: bool) -> Vec { + let mut points: Vec = pts.iter().map(|i| self.p(*i)).collect(); + if periodic + && let Some(first) = points.first().copied() + && let Some(last) = points.last_mut() + { + *last = first; + } + points + } + 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, periodic }) => { + let p = self.spline_points(pts, *periodic); + 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_mode(&p, *periodic) + .unwrap_or_default() + .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) { @@ -204,6 +263,17 @@ impl State<'_> { _ => 0.0, } } + fn arc_sweep(&self, c: usize) -> f64 { + let Some(curve) = self.sk.curves.get(c) else { return 0.0 }; + let CurveKind::Arc { c: cp, a, b } = curve.kind else { return 0.0 }; + let sweep = ((self.p(b) - self.p(cp)).angle() - (self.p(a) - self.p(cp)).angle()).rem_euclid(std::f64::consts::TAU); + if curve.reversed { -sweep } else { sweep } + } + fn slot_length(&self, a: usize, b: usize) -> f64 { + let (ca, ra) = self.round(a); + let (cb, rb) = self.round(b); + ca.dist(cb) + ra + rb + } /// Curvature vector of a curve at its point `p` (towards the centre of curvature). fn curvature_at(&self, c: usize, p: usize) -> Vec2 { let Some(kind) = self.sk.curves.get(c).map(|c| &c.kind) else { return Vec2::ZERO }; @@ -213,10 +283,10 @@ impl State<'_> { let (cc, r) = self.round(c); (cc - self.p(p)) / (r * r).max(1e-24) } - CurveKind::Spline { pts, control, degree } => { - let q: Vec = pts.iter().map(|i| self.p(*i)).collect(); + CurveKind::Spline { pts, control, degree, periodic } => { + let q = self.spline_points(pts, *periodic); let at_end = pts.last() == Some(&p) && pts.first() != Some(&p); - crate::curves::end_curvature(&|t| crate::curves::spline_point(&q, *control, *degree, t), at_end) + crate::curves::end_curvature(&|t| crate::curves::spline_point_mode(&q, *control, *degree, *periodic, t), at_end) } CurveKind::Conic { a, b, apex, rho } => { let (pa, pb, px) = (self.p(*a), self.p(*b), self.p(*apex)); @@ -229,9 +299,9 @@ impl State<'_> { match self.sk.curves.get(c).map(|c| &c.kind)? { CurveKind::Line { a, b } => (self.p(*b) - self.p(*a)).normalized(), CurveKind::Circle { .. } | CurveKind::Arc { .. } => (self.p(p) - self.round(c).0).perp().normalized(), - CurveKind::Spline { pts, control, .. } => { - let q: Vec = pts.iter().map(|i| self.p(*i)).collect(); - crate::curves::spline_end_tangent(&q, *control, pts.last() == Some(&p) && pts.first() != Some(&p)) + CurveKind::Spline { pts, control, periodic, .. } => { + let q = self.spline_points(pts, *periodic); + crate::curves::spline_end_tangent_mode(&q, *control, *periodic, pts.last() == Some(&p) && pts.first() != Some(&p)) } CurveKind::Conic { a, b, apex, .. } => { if p == *a { @@ -281,7 +351,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)); @@ -388,6 +458,18 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { let (a, b) = s.line(l); out.push(a.dist(b) - value); } + LineAngle { l, value } => { + let (a, b) = s.line(l); + // A dimension with no typed length must not satisfy its angle by collapsing + // the line. Scale with the original geometry, independently of the unknowns. + let scale = match s.sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => s.sk.point(*a).zip(s.sk.point(*b)).map(|(a, b)| a.dist(b)).unwrap_or(1.0), + _ => 1.0, + }; + out.push(wrap_angle((b - a).angle() - value) * scale.max(1.0)); + } + ArcSweep { c, value } => out.push((s.arc_sweep(c) - value) * s.sk.radius(c).unwrap_or(1.0).max(1.0)), + SlotOverallLength { a, b, value } => out.push(s.slot_length(a, b) - value), Radius { c, value } => out.push(s.radius_of(c) - value), Diameter { c, value } => out.push(2.0 * s.round(c).1 - value), Angle { a, b, value, flip } => { @@ -438,6 +520,12 @@ fn measure(s: &State, k: &ConstraintKind) -> Option { let (a, b) = s.line(l); a.dist(b) } + LineAngle { l, value } => { + let (a, b) = s.line(l); + value + wrap_angle((b - a).angle() - value) + } + ArcSweep { c, .. } => s.arc_sweep(c), + SlotOverallLength { a, b, .. } => s.slot_length(a, b), Radius { c, .. } => s.radius_of(c), Diameter { c, .. } => 2.0 * s.round(c).1, Angle { a, b, flip, .. } => { @@ -466,6 +554,7 @@ pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option { /// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known /// touch point (better conditioned than the distance form when an end point is on the curve). enum Block<'a> { + Closure { a: usize, b: usize }, Arc { c: usize, a: usize, b: usize }, User(usize, &'a ConstraintKind), TangentAt(usize, &'a ConstraintKind, usize), @@ -473,6 +562,7 @@ enum Block<'a> { fn copy_block<'a>(b: &Block<'a>) -> Block<'a> { match b { + Block::Closure { a, b } => Block::Closure { a: *a, b: *b }, Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b }, Block::User(i, k) => Block::User(*i, k), Block::TangentAt(i, k, p) => Block::TangentAt(*i, k, *p), @@ -481,6 +571,10 @@ fn copy_block<'a>(b: &Block<'a>) -> Block<'a> { fn block_residuals(s: &State, b: &Block, out: &mut Vec) { match b { + Block::Closure { a, b } => { + let d = s.p(*a) - s.p(*b); + out.extend([d.x, d.y]); + } Block::Arc { c, a, b } => { let cc = s.p(*c); out.push(cc.dist(s.p(*b)) - cc.dist(s.p(*a))); @@ -551,6 +645,10 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { }; use ConstraintKind::*; match b { + Block::Closure { a, b } => { + pt(&mut v, *a); + pt(&mut v, *b); + } Block::Arc { c, a, b } => { pt(&mut v, *c); pt(&mut v, *a); @@ -569,14 +667,21 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { pt(&mut v, p); curve(&mut v, c); } - Horizontal { l } | Vertical { l } | Length { l, .. } | Radius { c: l, .. } | Diameter { c: l, .. } => curve(&mut v, l), + Horizontal { l } + | Vertical { l } + | Length { l, .. } + | LineAngle { l, .. } + | ArcSweep { c: l, .. } + | Radius { c: l, .. } + | Diameter { c: l, .. } => curve(&mut v, l), Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } - | Angle { a, b, .. } => { + | Angle { a, b, .. } + | SlotOverallLength { a, b, .. } => { curve(&mut v, a); curve(&mut v, b); } @@ -801,6 +906,20 @@ pub fn solve(sk: &mut Sketch) -> SolveReport { /// constraint between two entities holds the first so only the second moves. Everything else /// moves as little as it can. On failure the geometry is restored. pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { + let mut orientation_before = Vec::new(); + for constraint in &sk.constraints { + if !constraint.driven + && let ConstraintKind::ArcSweep { c, value } = constraint.kind + && value.is_finite() + && let Some(curve) = sk.curves.get_mut(c) + && let CurveKind::Arc { a, b, .. } = &mut curve.kind + && curve.reversed != (value < 0.0) + { + orientation_before.push((c, *a, *b, curve.reversed)); + std::mem::swap(a, b); + curve.reversed = value < 0.0; + } + } let lay = layout(sk, hold); let mut x = vec![0.0; lay.n]; for (i, p) in sk.points.iter().enumerate() { @@ -821,9 +940,45 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { } } let x0 = x.clone(); + // Exactly opposite directions sit on the wrapped angle's derivative discontinuity. + // Nudge one free endpoint into a continuous branch before Newton iterations. The + // untouched x0 still restores the complete original geometry on a failed solve. + for constraint in &sk.constraints { + if constraint.driven { + continue; + } + let ConstraintKind::LineAngle { l, value } = constraint.kind else { continue }; + let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { continue }; + let (pa, pb) = { + let state = State { sk, lay: &lay, x: &x }; + (state.p(*a), state.p(*b)) + }; + let delta = wrap_angle((pb - pa).angle() - value); + if delta.abs() < std::f64::consts::PI - 1e-5 || !delta.is_finite() { + continue; + } + let d = Vec2::from_angle((pb - pa).angle() - delta.signum() * 0.01) * pa.dist(pb); + let (point, at) = if lay.pvar.get(*b).copied().flatten().is_some() { (*b, pa + d) } else { (*a, pb - d) }; + if let Some(index) = lay.pvar.get(point).copied().flatten() { + if let Some(v) = x.get_mut(index) { + *v = at.x; + } + if let Some(v) = x.get_mut(index + 1) { + *v = at.y; + } + } + } let mut blocks: Vec<(Block, Vec)> = Vec::new(); for c in &sk.curves { + if let CurveKind::Spline { pts, periodic: true, .. } = &c.kind + && let (Some(a), Some(b)) = (pts.first(), pts.last()) + && a != b + { + let bl = Block::Closure { a: *a, b: *b }; + let vars = block_vars(sk, &lay, &bl); + blocks.push((bl, vars)); + } if let CurveKind::Arc { c, a, b } = c.kind { let bl = Block::Arc { c, a, b }; let vars = block_vars(sk, &lay, &bl); @@ -944,6 +1099,63 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { let (r, _, owner) = system(sk, &lay, &consts, &mut x, false); check(&r, &owner, &consts, &mut failing); } + // Weld the whole connected closure graph, preserving every immutable node. + // Validate the actual prospective geometry before accepting a numerical solve. + let has_periodic = sk.curves.iter().any(|c| matches!(c.kind, CurveKind::Spline { periodic: true, .. })); + let periodic_positions = if has_periodic { + let state = State { sk, lay: &lay, x: &x }; + let mut positions = (0..sk.points.len()).map(|i| state.p(i)).collect::>(); + let mut parent = (0..positions.len()).collect::>(); + for curve in &sk.curves { + if let CurveKind::Spline { pts, periodic: true, .. } = &curve.kind + && let (Some(a), Some(b)) = (pts.first(), pts.last()) + && *a < parent.len() + && *b < parent.len() + { + let (a, b) = (find(&mut parent, *a), find(&mut parent, *b)); + if let Some(slot) = parent.get_mut(b) { + *slot = a; + } + } + } + let mut representatives = std::collections::BTreeMap::new(); + let mut valid = true; + for i in 0..positions.len() { + let root = find(&mut parent, i); + if let Some(previous) = representatives.get(&root).copied() { + let fixed = |p| lay.pvar.get(p).is_some_and(Option::is_none); + if fixed(i) && fixed(previous) && positions[i] != positions[previous] { + valid = false; + } + if fixed(i) && !fixed(previous) { + representatives.insert(root, i); + } + } else { + representatives.insert(root, i); + } + } + for (i, position) in positions.iter_mut().enumerate() { + if let Some(index) = representatives.get(&find(&mut parent, i)) { + *position = state.p(*index); + } + } + for curve in &sk.curves { + if let CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind { + let points = pts.iter().map(|i| positions.get(*i).copied()).collect::>>(); + if *control || *degree != 3 || points.as_ref().is_none_or(|p| crate::periodic::validate_periodic_points(p).is_err()) { + valid = false; + } + } + } + if !valid { + max_residual = max_residual.max(1.0); + None + } else { + Some(positions) + } + } else { + None + }; let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed }; let use_x = if status == SolveStatus::Solved { &x } else { &x0 }; // Write back. @@ -957,6 +1169,24 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { *r = use_x.get(v).copied().unwrap_or(*r).abs().max(1e-9); } } + if status == SolveStatus::Solved + && let Some(positions) = periodic_positions + { + for (point, position) in sk.points.iter_mut().zip(positions) { + point.pos = position; + } + } + if status == SolveStatus::Failed { + for (ci, a_before, b_before, reversed) in orientation_before.into_iter().rev() { + if let Some(curve) = sk.curves.get_mut(ci) + && let CurveKind::Arc { a, b, .. } = &mut curve.kind + { + *a = a_before; + *b = b_before; + curve.reversed = reversed; + } + } + } let vdet = |v: Option| v.is_none_or(|v| det.get(v).copied().unwrap_or(false)); let pdet = |p: usize| match lay.pvar.get(p).copied().flatten() { Some(v) => vdet(Some(v)) && vdet(Some(v + 1)), @@ -986,3 +1216,38 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { curve_determined, } } + +#[cfg(test)] +mod typed_dimension_tests { + use super::*; + + #[test] + fn failed_signed_sweep_restores_arc_orientation_and_coordinates() { + let mut sk = Sketch::new(); + let c = sk.add_arc(Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 10.0), [Some(0), None, None], None).unwrap(); + let id = sk.add_constraint(ConstraintKind::ArcSweep { c, value: std::f64::consts::FRAC_PI_2 }, None).unwrap(); + sk.curves[c].fixed = true; + assert!(solve(&mut sk).ok()); + let before = sk.curves.clone(); + let points = sk.points.clone(); + sk.constraints.iter_mut().find(|constraint| constraint.id == id).unwrap().kind.set_value(-std::f64::consts::FRAC_PI_2); + assert!(!solve(&mut sk).ok()); + assert_eq!(sk.curves, before); + assert_eq!(sk.points, points); + } + + #[test] + fn signed_dimensions_validate_kinds_values_and_survive_curve_removal() { + let mut sk = Sketch::new(); + let l = sk.add_line(Vec2::ZERO, Vec2::new(10.0, 10.0), Some(0), None, None).unwrap(); + assert!(sk.add_constraint(ConstraintKind::ArcSweep { c: l, value: 1.0 }, None).is_err()); + assert!(sk.add_constraint(ConstraintKind::LineAngle { l, value: f64::NAN }, None).is_err()); + sk.add_constraint(ConstraintKind::LineAngle { l, value: -std::f64::consts::FRAC_PI_4 }, None).unwrap(); + assert!(solve(&mut sk).ok()); + let CurveKind::Line { a, b } = sk.curves[l].kind else { panic!("line") }; + let direction = sk.points[b].pos - sk.points[a].pos; + assert!((direction.angle() + std::f64::consts::FRAC_PI_4).abs() < 1e-7); + sk.remove_curves(&[l]); + assert!(sk.constraints.is_empty()); + } +} diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index a7a2ca51..4953dce6 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -424,7 +424,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() { .iter() .map(|p| sk.add_point(*p, None).unwrap()) .collect(); - sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5 }, None).unwrap(); + sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5, periodic: false }, None).unwrap(); sk.add_line_pts(ids[5], ids[0], None).unwrap(); let pr = find_profiles(&sk); assert_eq!(pr.len(), 1); @@ -433,7 +433,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() { // A fit spline arch over a base line, cut by a vertical line at x = 10. let mut sk = Sketch::default(); let ids: Vec = [v(0.0, 0.0), v(6.0, 9.0), v(14.0, 9.0), v(20.0, 0.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); - sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3 }, None).unwrap(); + sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3, periodic: false }, None).unwrap(); sk.add_line_pts(ids[3], ids[0], None).unwrap(); let (a, b) = (sk.add_point(v(10.0, -5.0), None).unwrap(), sk.add_point(v(10.0, 20.0), None).unwrap()); sk.add_line_pts(a, b, None).unwrap(); diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs index ab0fb296..178588b2 100644 --- a/crates/sketch/src/wire.rs +++ b/crates/sketch/src/wire.rs @@ -1,4 +1,4 @@ -//! 3D sketch curves (wires): polylines in world coordinates that a sketch carries besides its +//! 3D sketch curves (wires): polylines with explicit coordinates that a sketch carries besides its //! planar geometry — included 3D edges, curves projected onto surfaces, intersection curves. //! They are reference geometry: drawn and usable as paths, not part of profiles or the solver. @@ -11,11 +11,35 @@ use crate::model::{Result, Sketch, SketchError}; /// Most points one wire may have. pub const MAX_WIRE_POINTS: usize = 100_000; +/// Coordinate meaning of a stored wire and its editable fit points. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireSpace { + /// Legacy drawn or detached geometry remains physically fixed in the world. + #[default] + World, + /// Geometry follows the sketch's owning component placement. + Component, +} + +impl WireSpace { + /// Coordinate semantics of a retained legacy producer, including a currently lost link. + pub fn from_legacy_link(link: &Link) -> Self { + match (&link.kind, &link.source) { + (LinkKind::Include, LinkSource::Edge { .. } | LinkSource::SketchCurve { .. }) + | (_, LinkSource::OnSurface { .. } | LinkSource::Intersection { .. } | LinkSource::Iso { .. }) => Self::Component, + _ => Self::World, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Wire { pub id: String, - /// World-space polyline. + /// Polyline in `space` coordinates. pub pts: Vec, + #[serde(default)] + pub space: WireSpace, #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, /// Drawn in a 3D sketch: the points the curve was drawn through (one: a point; two: a @@ -68,8 +92,11 @@ pub fn fit_polyline(fit: &[Vec3]) -> Vec { } fn clean(w: &[Vec3]) -> Option> { + if w.len() > MAX_WIRE_POINTS { + return None; + } let mut out: Vec = Vec::new(); - for p in w.iter().take(MAX_WIRE_POINTS) { + for p in w { if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) { return None; } @@ -80,7 +107,77 @@ fn clean(w: &[Vec3]) -> Option> { (out.len() >= 2).then_some(out) } +fn clean_wires(wires: &[Vec]) -> Result>> { + if wires.len() > crate::link::MAX_LINK_ENTITIES || wires.iter().fold(0usize, |n, w| n.saturating_add(w.len())) > 1_000_000 { + return Err(SketchError::Invalid("3D linked curves exceed the stored geometry budget".into())); + } + wires.iter().map(|w| clean(w).ok_or_else(|| SketchError::Invalid("3D linked curve points out of range or coincident".into()))).collect() +} + +fn drawn_polyline(fit: &[Vec3]) -> Result> { + if fit.is_empty() || fit.len() > MAX_FIT_POINTS { + return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points"))); + } + if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) { + return Err(SketchError::Invalid("3D point out of range".into())); + } + if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) { + return Err(SketchError::Invalid("the points of a 3D curve must differ".into())); + } + let pts = fit_polyline(fit); + if pts.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) { + return Err(SketchError::Invalid("3D spline samples out of range".into())); + } + Ok(pts) +} + impl Sketch { + fn wire_budget(&self, replacing: Option<&str>, new_count: usize, new_points: usize) -> Result<()> { + let mut count = new_count; + let mut points = new_points; + for w in self.wires.iter().filter(|w| replacing.is_none() || w.link.as_deref() != replacing) { + count = count.saturating_add(1); + points = points.saturating_add(w.pts.len()).saturating_add(w.fit.len()); + } + if count > crate::link::MAX_LINK_ENTITIES || points > 1_000_000 { + return Err(SketchError::Invalid("3D curves exceed the stored geometry budget".into())); + } + Ok(()) + } + + /// Validate saved arrays without regenerating legacy geometry or changing point values. + pub fn validate_wires(&self) -> Result<()> { + if self.wires.len() > crate::link::MAX_LINK_ENTITIES { + return Err(SketchError::Invalid("too many 3D curves".into())); + } + let mut work = 0usize; + let mut ids = std::collections::BTreeSet::new(); + for w in &self.wires { + work = work.saturating_add(w.pts.len()).saturating_add(w.fit.len()); + if work > 1_000_000 + || w.id.is_empty() + || w.id.len() > 512 + || !ids.insert(&w.id) + || w.link.as_ref().is_some_and(|id| id.is_empty() || id.len() > 512) + || w.pts.is_empty() + || w.pts.len() > MAX_WIRE_POINTS + || w.fit.len() > MAX_FIT_POINTS + || (w.pts.len() == 1 && w.fit.len() != 1) + || w.pts.iter().chain(&w.fit).any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) + { + return Err(SketchError::Invalid("3D curve arrays, ids or point bounds are invalid".into())); + } + } + Ok(()) + } + + /// Producer semantics for legacy wires with retained association provenance. Breaking a + /// link removes this evidence, so detached wires conservatively retain world coordinates. + pub fn legacy_wire_space(&self, wire: &Wire) -> WireSpace { + let Some(link) = wire.link.as_deref().and_then(|id| self.link(id)) else { return WireSpace::World }; + WireSpace::from_legacy_link(link) + } + pub fn wire_index(&self, id: &str) -> Option { self.wires.iter().position(|w| w.id == id) } @@ -92,13 +189,14 @@ impl Sketch { /// Add a link with planar geometry and/or 3D wires. pub fn add_link_with_wires(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom], wires: &[Vec]) -> Result { - let ws: Vec> = wires.iter().filter_map(|w| clean(w)).collect(); + let ws = clean_wires(wires)?; + self.wire_budget(None, ws.len(), ws.iter().map(Vec::len).sum())?; if ws.is_empty() { return self.add_link(kind, source, geom); } let id = if geom.is_empty() { let id = self.fresh("j"); - self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false }); + self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false }); id } else { self.add_link(kind, source, geom)? @@ -112,7 +210,8 @@ impl Sketch { if self.link(id).is_none() { return Err(SketchError::Unknown(id.into())); } - let ws: Vec> = wires.iter().filter_map(|w| clean(w)).collect(); + let ws = clean_wires(wires)?; + self.wire_budget(Some(id), ws.len(), ws.iter().map(Vec::len).sum())?; let old: Vec = self.link_wires(id).iter().filter_map(|i| self.wires.get(*i).map(|w| w.id.clone())).collect(); self.wires.retain(|w| w.link.as_deref() != Some(id)); for (k, pts) in ws.into_iter().enumerate() { @@ -120,24 +219,22 @@ impl Sketch { Some(o) => o.clone(), None => self.fresh("w"), }; - self.wires.push(Wire { id: wid, pts, link: Some(id.to_string()), fit: Vec::new() }); + self.wires.push(Wire { id: wid, pts, space: WireSpace::Component, link: Some(id.to_string()), fit: Vec::new() }); } Ok(()) } /// Add a curve drawn in a 3D sketch through `fit` (see [`Wire::fit`]). pub fn add_drawn_wire(&mut self, fit: &[Vec3]) -> Result { - if fit.is_empty() || fit.len() > MAX_FIT_POINTS { - return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points"))); - } - if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) { - return Err(SketchError::Invalid("3D point out of range".into())); - } - if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) { - return Err(SketchError::Invalid("the points of a 3D curve must differ".into())); - } + self.add_drawn_wire_in(fit, WireSpace::World) + } + + /// Add a drawn curve without changing the coordinate meaning supplied by its author. + pub fn add_drawn_wire_in(&mut self, fit: &[Vec3], space: WireSpace) -> Result { + let pts = drawn_polyline(fit)?; + self.wire_budget(None, 1, pts.len() + fit.len())?; let id = self.fresh(if fit.len() == 1 { "q" } else { "w" }); - self.wires.push(Wire { id: id.clone(), pts: fit_polyline(fit), link: None, fit: fit.to_vec() }); + self.wires.push(Wire { id: id.clone(), pts, space, link: None, fit: fit.to_vec() }); Ok(id) } @@ -148,17 +245,32 @@ impl Sketch { return Err(SketchError::Invalid("3D point out of range".into())); } let w = self.wire_index(wire).ok_or_else(|| SketchError::Unknown(wire.into()))?; + let space = self + .wires + .get(w) + .filter(|w| w.link.is_none()) + .map(|w| w.space) + .ok_or_else(|| SketchError::Invalid("linked 3D geometry cannot be moved".into()))?; let from = self.wires.get(w).and_then(|w| w.fit.get(index)).copied().ok_or_else(|| SketchError::Unknown(format!("{wire} point {index}")))?; let mut n = 0; - for w in self.wires.iter_mut().filter(|w| !w.fit.is_empty()) { + let mut changes = Vec::new(); + for (index, w) in self.wires.iter().enumerate().filter(|(_, w)| !w.fit.is_empty() && w.link.is_none() && w.space == space) { + let mut fit = w.fit.clone(); let mut changed = false; - for p in w.fit.iter_mut().filter(|p| p.dist(from) < 1e-9) { + for p in fit.iter_mut().filter(|p| p.dist(from) < 1e-9) { *p = to; changed = true; n += 1; } if changed { - w.pts = fit_polyline(&w.fit); + let pts = drawn_polyline(&fit)?; + changes.push((index, fit, pts)); + } + } + for (index, fit, pts) in changes { + if let Some(w) = self.wires.get_mut(index) { + w.fit = fit; + w.pts = pts; } } Ok(n) @@ -170,3 +282,52 @@ impl Sketch { self.prune_links(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_space_defaults_and_preserves_detached_provenance() { + let mut sk = Sketch::new(); + let id = sk.add_drawn_wire(&[Vec3::new(1.0, 2.0, 3.0)]).unwrap(); + assert_eq!(sk.wires[0].space, WireSpace::World); + let mut raw = serde_json::to_value(&sk.wires[0]).unwrap(); + raw.as_object_mut().unwrap().remove("space"); + assert_eq!(serde_json::from_value::(raw).unwrap().space, WireSpace::World); + let link = sk + .add_link_with_wires( + LinkKind::Include, + LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO }, + &[], + &[vec![Vec3::new(1.0, 2.0, 3.0), Vec3::new(1.0, 2.0, 4.0)]], + ) + .unwrap(); + sk.set_link_lost(&link, true); + let wire = sk.wires.iter().find(|w| w.link.as_deref() == Some(&link)).unwrap(); + assert_eq!(sk.legacy_wire_space(wire), WireSpace::Component); + let wid = wire.id.clone(); + sk.break_link(&link).unwrap(); + let wire = sk.wires.iter().find(|w| w.id == wid).unwrap(); + assert_eq!(wire.space, WireSpace::Component); + assert_eq!(sk.legacy_wire_space(wire), WireSpace::World); + assert_eq!(sk.wires.iter().find(|w| w.id == id).unwrap().space, WireSpace::World); + } + + #[test] + fn wire_space_invalid_producers_and_move_are_atomic() { + let mut sk = Sketch::new(); + let wire = sk.add_drawn_wire_in(&[Vec3::ZERO, Vec3::Z], WireSpace::Component).unwrap(); + sk.add_drawn_wire(&[Vec3::ZERO]).unwrap(); + assert_eq!(sk.move_drawn_point(&wire, 0, Vec3::X).unwrap(), 1); + assert_eq!(sk.wires[1].fit[0], Vec3::ZERO); + let before = sk.clone(); + assert!(sk.move_drawn_point(&wire, 0, Vec3::Z).is_err()); + assert_eq!(sk, before); + assert!(sk.add_link_with_wires(LinkKind::Include, LinkSource::Origin, &[], &[vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]]).is_err()); + assert_eq!(sk, before); + assert!(sk.add_link_with_wires(LinkKind::Include, LinkSource::Origin, &[], &[vec![Vec3::ZERO; MAX_WIRE_POINTS + 1]]).is_err()); + assert_eq!(sk, before); + sk.validate_wires().unwrap(); + } +} diff --git a/crates/ui-egui/src/access.rs b/crates/ui-egui/src/access.rs index 7fc001e4..69b3d2c8 100644 --- a/crates/ui-egui/src/access.rs +++ b/crates/ui-egui/src/access.rs @@ -27,7 +27,7 @@ pub fn map(app: &SolveApp) -> Option { return Some(m); } let st = app.session.world_state(); - let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect(); + let meshes: Vec<_> = st.bodies.iter().filter(|b| app.session.body_visible(&b.name)).map(|b| b.mesh()).collect(); // Every new map gets a new version (the GPU uploads it once). let version = VERSION.with(|v| { v.set(v.get() + 1); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 5fbc0c8f..e07bf0b2 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -6,6 +6,10 @@ //! viewport, a double-click on a sketch edits it, and a right-click opens the item's menu //! ([`crate::context_menu`]). +#[cfg(test)] +#[path = "occurrence_ui_tests.rs"] +mod occurrence_ui_tests; + use std::collections::BTreeSet; use egui::{Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; @@ -21,6 +25,7 @@ thread_local! { /// The rows drawn this frame: (label, row, fold arrow area, open). For automation /// (`ui.at {browser | chevron}`, `ui.browser`). static DRAWN: std::cell::RefCell, Option)>> = const { std::cell::RefCell::new(Vec::new()) }; + static OCCURRENCES: std::cell::RefCell, String, Rect, bool)>> = const { std::cell::RefCell::new(Vec::new()) }; } /// The rows on screen: (label, row rect, fold arrow area, open). @@ -28,6 +33,10 @@ pub fn drawn_rows() -> Vec<(String, Rect, Option, Option)> { DRAWN.with(|d| d.borrow().clone()) } +pub fn drawn_occurrences() -> Vec<(Vec, String, Rect, bool)> { + OCCURRENCES.with(|d| d.borrow().clone()) +} + /// Browser state kept between frames. #[derive(Default)] pub struct TreeState { @@ -41,6 +50,7 @@ pub struct TreeState { pub occurrence_move: Option, /// The Redefine Sketch Plane panel (sketch id). pub redefine: Option, + pub redefine_draft: Option, /// Components picked in the tree (they have no viewport selection). pub picked_components: Vec, /// Canvases picked in the tree. @@ -59,6 +69,11 @@ pub struct TreeState { #[derive(Clone, Debug)] pub struct OccMove { pub occurrence: u64, + /// Exact assembly placement used for world-space movement and the pivot. + pub path: Vec, + /// Effective local placement at dialog opening, including its pre-existing pending move. + pub baseline: Option, + pub had_pending: bool, /// Distances along X, Y, Z (mm) and turns about X, Y, Z (degrees) from where it was. pub translate: [f64; 3], pub angles: [f64; 3], @@ -70,7 +85,7 @@ pub struct OccMove { impl OccMove { pub fn new(occurrence: u64) -> Self { - OccMove { occurrence, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None } + OccMove { occurrence, path: Vec::new(), baseline: None, had_pending: false, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None } } } @@ -98,6 +113,7 @@ pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) { pub fn redefine_sketch(app: &mut SolveApp, id: u64) { app.tree.redefine = Some(id); + app.tree.redefine_draft = Some(crate::sketch_attachment_ui::Draft::new(app, id)); } fn find_group(app: &SolveApp, id: u64) -> Option<&solvecraft_engine::doc::BrowserGroup> { @@ -188,7 +204,7 @@ pub fn selection_group(app: &SolveApp, target: &Target) -> Option { Target::Body { name } => { let st = app.session.world_state(); let b = st.body(name)?; - (app.session.doc.body_component(name, b.feature), "bodies") + (b.component, "bodies") } Target::Sketch { id } => (app.session.doc.feature(*id)?.component, "sketches"), _ => return None, @@ -440,6 +456,13 @@ fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option } fn sel_of_key(k: &str) -> Option { + if let Some((_, id)) = k.rsplit_once("/s:") { + return id.parse().ok().map(|id| Sel::Feature { id }); + } + if let Some(p) = k.strip_prefix("o:") { + let path: Option> = p.split('/').map(|id| id.parse().ok()).collect(); + return path.filter(|p| !p.is_empty()).map(|path| Sel::Occurrence { path }); + } if let Some(b) = k.strip_prefix("b:") { return Some(Sel::Body { name: b.to_string() }); } @@ -475,6 +498,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); app.tree.order.clear(); DRAWN.with(|d| d.borrow_mut().clear()); + OCCURRENCES.with(|d| d.borrow_mut().clear()); let mut acts = Actions::default(); egui::Panel::left("sc_browser") .exact_size(250.0) @@ -501,7 +525,8 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { occurrence_panel(app, ui); capture_prompt(app, ui.ctx()); canvas_panel(app, ui.ctx()); - redefine_panel(app, ui.ctx()); + let repair_anchor = app.viewport.rect.map(|rect| rect.right_top() + vec2(-16.0, 16.0)).unwrap_or_else(|| ui.ctx().content_rect().right_top()); + crate::sketch_attachment_ui::panel(app, ui.ctx(), repair_anchor); } fn component_name(app: &SolveApp, id: u64) -> String { @@ -598,9 +623,109 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, if id == 0 { crate::dialogs_assembly::browser_joints(app, ui, d); } - let children: Vec = app.session.doc.components.iter().filter(|c| c.parent == id).map(|c| c.id).collect(); - for c in children { - component_rows(app, ui, c, d, acts); + let children: Vec = app.session.doc.occurrences.iter().filter(|o| o.parent == id).map(|o| o.id).collect(); + for occurrence in children { + occurrence_rows(app, ui, vec![occurrence], d, acts); + } +} + +fn occurrence_key(path: &[u64]) -> String { + format!("o:{}", path.iter().map(u64::to_string).collect::>().join("/")) +} + +/// A placement, rather than a component definition: repeated child IDs remain distinct +/// because their complete paths are the selection and row identity. +fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec, depth: usize, acts: &mut Actions) { + let Ok(placement) = app.session.occurrence_placement(&path) else { return }; + let key = occurrence_key(&path); + let open = is_open(app, &key, true); + let active = app.session.active_occurrence_path.as_deref() == Some(path.as_slice()) + || (app.session.active_occurrence_path.is_none() + && app.session.active_component == placement.component + && app.session.doc.first_path(placement.component) == path); + let visible = app.session.occurrence_visible(&path); + let own_visible = !app.session.occurrence_hidden(&path); + let selected = app.session.selection.contains(&Sel::Occurrence { path: path.clone() }); + let faded = app.session.active_occurrence_path.as_ref().is_some_and(|p| !path.starts_with(p) && !p.starts_with(&path)); + let badges: &[&str] = if placement.grounded { &["pin"] } else { &[] }; + app.tree.order.push(key.clone()); + let r = draw_row( + ui, + ui.id().with(&key), + &Row { + depth, + fold: Some(open), + eye: Some(own_visible), + icon: "component", + label: &placement.name, + selected, + dim: faded || !visible, + radio: Some(active), + badges, + ..Default::default() + }, + ); + if let Some(rect) = r.rect.filter(|_| r.resp.is_some()) { + OCCURRENCES.with(|d| d.borrow_mut().push((path.clone(), placement.name.clone(), rect, open))); + crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(rect.right() - 12.0, rect.center().y)); + let x = rect.left() + 2.0 + depth as f32 * INDENT; + for (control, offset) in [("fold", 6.0), ("eye", 22.0), ("activate", 38.0)] { + crate::scenario::publish_handle(&format!("{control}:occurrence:{}", key.trim_start_matches("o:")), pos2(x + offset, rect.center().y)); + } + } + if let Some(resp) = &r.resp { + let definition = app.session.doc.components.iter().find(|c| c.id == placement.component).map(|c| c.name.as_str()).unwrap_or(""); + let location = path + .iter() + .filter_map(|id| app.session.doc.occurrences.iter().find(|o| o.id == *id)) + .map(|o| o.name.as_str()) + .collect::>() + .join(" / "); + let inherited = if own_visible && !visible { "\nHidden by a parent placement. Show the parent to reveal this instance." } else { "" }; + resp.clone().on_hover_text(format!( + "Component: {definition}\nPlacement: {}\nGeometry edits apply to every instance of this component.{inherited}", + location + )); + } + if r.fold { + toggle(app, &key, true); + } + if r.eye { + let _ = app.run("browser.visibility", json!({"visible": app.session.occurrence_hidden(&path), "paths": [path]})); + } + if r.radio || r.double { + let _ = app.run("component.activate", json!({"component": placement.component, "occurrence": placement.occurrence, "path": path})); + } + if r.clicked { + click_select(app, ui, key.clone(), Some(Sel::Occurrence { path: path.clone() })); + } + if r.hovered { + acts.hover_bodies = app.session.occurrence_bodies(&path, true).unwrap_or_default(); + } + if let Some(p) = r.secondary { + if !selected { + let _ = app.run("select.set", json!({"items": [Sel::Occurrence { path: path.clone() }]})); + } + acts.menu = Some((p, Target::Occurrence { path: path.clone() })); + } + if let Some(resp) = &r.resp + && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != placement.component && d.folder != "construction", DropMark::Box) + { + acts.drop = Some(DropAction::Component { to: placement.component, folder: d.folder, keys: d.keys.clone() }); + } + if !open { + return; + } + for folder in ["bodies", "sketches", "construction"] { + occurrence_folder_rows(app, ui, placement.component, &path, folder, depth + 1, acts); + } + let children: Vec = app.session.doc.occurrences.iter().filter(|o| o.parent == placement.component).map(|o| o.id).collect(); + if path.len() < 64 { + for occurrence in children { + let mut child = path.clone(); + child.push(occurrence); + occurrence_rows(app, ui, child, depth + 1, acts); + } } } @@ -998,13 +1123,13 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { .state() .bodies .iter() - .filter(|b| doc.body_component(&b.name, b.feature) == comp) + .filter(|b| doc.model_body_component(b) == comp) .map(|b| Entry { key: b.name.clone(), row_key: format!("b:{}", b.name), label: b.name.clone(), icon: "body", - visible: !app.session.visibility.hidden_bodies.contains(&b.name), + visible: app.session.body_visible(&b.name), selected: sel.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)), color: None, locked: app.ui.locked_bodies.contains(&b.name), @@ -1019,14 +1144,22 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. })) .map(|f| { let active = app.session.active_sketch == Some(f.id); + let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some()) + && matches!(doc.sketch_face_attachment(f.id, &app.session.model), solvecraft_engine::doc::FaceAttachmentStatus::Lost { .. }); Entry { key: f.id.to_string(), row_key: format!("s:{}", f.id), - label: if active { format!("{} (editing)", f.name) } else { f.name.clone() }, + label: if lost { + format!("{} (attachment lost)", f.name) + } else if active { + format!("{} (editing)", f.name) + } else { + f.name.clone() + }, icon: "sketch", visible: sketch_visible(app, f.id), selected: active || sel.iter().any(|x| matches!(x, Sel::Feature { id } if *id == f.id)), - color: active.then_some(t.sketch_accent), + color: if lost { Some(t.error) } else { active.then_some(t.sketch_accent) }, locked: false, sel: Some(Sel::Feature { id: f.id }), target: Some(Target::Sketch { id: f.id }), @@ -1082,9 +1215,9 @@ fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) { for e in items { match folder { "bodies" => { - app.session.visibility.hidden_bodies.retain(|b| *b != e.key); + app.session.visibility.hidden_bodies.retain(|b| !e.bodies.contains(b)); if !on { - app.session.visibility.hidden_bodies.push(e.key.clone()); + app.session.visibility.hidden_bodies.extend(e.bodies.clone()); } } "sketches" => { @@ -1121,13 +1254,38 @@ fn folder_static(folder: &str) -> &'static str { /// A component's folder: its row, its groups and its items. Rows are drag sources; items, /// groups and the folder row are drop targets (reorder, into a group, out of groups). fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, depth: usize, acts: &mut Actions) { + folder_rows_at(app, ui, comp, None, folder, depth, acts); +} + +fn occurrence_folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: &[u64], folder: &str, depth: usize, acts: &mut Actions) { + folder_rows_at(app, ui, comp, Some(path), folder, depth, acts); +} + +fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option<&[u64]>, folder: &str, depth: usize, acts: &mut Actions) { let t = Tokens::get(); - let all = entries(app, comp, folder); + let mut all = entries(app, comp, folder); + let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}")); + if let Some(path) = path { + let Ok(placement) = app.session.occurrence_placement(path) else { return }; + for e in &mut all { + if folder == "bodies" { + let Some(body) = placement.bodies.iter().find(|b| b.source == e.key) else { continue }; + e.sel = Some(Sel::Body { name: body.world.clone() }); + e.target = Some(Target::Body { name: body.world.clone() }); + e.bodies = vec![body.world.clone()]; + e.visible = app.session.body_visible(&body.world); + e.selected = app.session.selection.contains(&Sel::Body { name: body.world.clone() }); + e.row_key = format!("b:{}", body.world); + } else { + e.row_key = format!("{prefix}/{}", e.row_key); + } + } + } if all.is_empty() { return; } let fstatic = folder_static(folder); - let key = format!("c{comp}/{folder}"); + let key = format!("{prefix}/{folder}"); if let Some(r) = app.tree.reveal.clone() && folder == "bodies" && all.iter().any(|e| e.key == r) @@ -1174,7 +1332,7 @@ fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, d let (groups, loose) = arrange(app, comp, folder, all); let loose_keys: Vec = loose.iter().map(|e| e.key.clone()).collect(); for (g, items) in &groups { - let gkey = format!("g{}", g.id); + let gkey = format!("{prefix}/g{}", g.id); let gopen = is_open(app, &gkey, true); let gvis = items.is_empty() || items.iter().any(|e| e.visible); let label = format!("{} ({})", g.name, items.len()); @@ -1203,12 +1361,12 @@ fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, d } if gopen { for e in items { - entry_row(app, ui, comp, fstatic, e, depth + 2, Some(g.id), &loose_keys, acts); + entry_row(app, ui, comp, path, fstatic, e, depth + 2, Some(g.id), &loose_keys, acts); } } } for e in &loose { - entry_row(app, ui, comp, fstatic, e, depth + 1, None, &loose_keys, acts); + entry_row(app, ui, comp, path, fstatic, e, depth + 1, None, &loose_keys, acts); } let _ = t; } @@ -1219,6 +1377,7 @@ fn entry_row( app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, + path: Option<&[u64]>, folder: &'static str, e: &Entry, depth: usize, @@ -1255,6 +1414,9 @@ fn entry_row( } if r.double && folder == "sketches" { if let Ok(id) = e.key.parse() { + if let Some(path) = path { + let _ = app.run("component.activate", json!({"component": comp,"occurrence":path.last(),"path":path})); + } app.edit_sketch(id); } } else if r.clicked { @@ -1367,9 +1529,29 @@ fn apply_drop(app: &mut SolveApp, d: DropAction) { /// Move/Copy of an occurrence: offsets and a rotation about Z, pending until Capture Position. fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) { let Some(mut m) = app.tree.occurrence_move.clone() else { return }; + if !m.path.is_empty() + && let Err(error) = app.session.occurrence_placement(&m.path) + { + app.tree.occurrence_move = None; + app.set_status(error.to_string(), true); + return; + } + if m.baseline.is_none() { + m.had_pending = app.session.pending_moves.contains_key(&m.occurrence); + m.baseline = app + .session + .pending_moves + .get(&m.occurrence) + .copied() + .or_else(|| app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.transform)); + } let ctx = ui.ctx().clone(); if m.pivot.is_none() { - m.pivot = occurrence_origin(app, m.occurrence); + m.pivot = if m.path.is_empty() { + occurrence_origin(app, m.occurrence) + } else { + app.session.occurrence_placement(&m.path).ok().map(|p| solvecraft_engine::doc::apply_point(&p.world, solvecraft_engine::geom::Vec3::ZERO)) + }; } let name = app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.name.clone()).unwrap_or_default(); let mut open = true; @@ -1383,28 +1565,33 @@ fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) { .resizable(false) .collapsible(false) .show(&ctx, |ui| { + if !m.path.is_empty() && app.session.occurrence_scope_count(&m.path).is_ok_and(|n| n > 1) { + ui.label("This child is shared. Position changes apply to every copy of its parent."); + } egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() { ui.label(*axis); if let Some(v) = m.translate.get_mut(k) { - ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm")); + let response = ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm")); + crate::scenario::publish_handle(&format!("occurrence-move:distance:{k}"), response.rect.center()); } ui.end_row(); } for (k, axis) in ["X angle", "Y angle", "Z angle"].iter().enumerate() { ui.label(*axis); if let Some(v) = m.angles.get_mut(k) { - ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg")); + let response = ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg")); + crate::scenario::publish_handle(&format!("occurrence-move:angle:{k}"), response.rect.center()); } ui.end_row(); } }); ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button("Capture Position").clicked() { + if ui.button("Capture All Positions").clicked() { action = Some("capture"); } - if ui.button("Revert").clicked() { + if ui.button("Revert All Positions").clicked() { action = Some("revert"); } }); @@ -1423,13 +1610,21 @@ fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) { app.tree.occurrence_move = None; } None if !open => { - let _ = app.run("component.revert_position", json!({})); + revert_occurrence_dialog(app, &m); app.tree.occurrence_move = None; } None => app.tree.occurrence_move = Some(m), } } +pub(crate) fn revert_occurrence_dialog(app: &mut SolveApp, m: &OccMove) { + if m.had_pending { + let _ = app.run("occurrence.move", json!({"occurrence":m.occurrence,"base_transform":m.baseline,"capture":false})); + } else { + let _ = app.run("component.revert_position", json!({"occurrences":[m.occurrence]})); + } +} + /// The pending move: turned about X, then Y, then Z through the starting origin, then moved. pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) { use crate::gizmo; @@ -1438,10 +1633,14 @@ pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) { let r = gizmo::mul(&gizmo::mul(&gizmo::rotation(Vec3::Z, a[2]), &gizmo::rotation(Vec3::Y, a[1])), &gizmo::rotation(Vec3::X, a[0])); let (axis, angle) = gizmo::axis_angle(&r); let o = m.pivot.unwrap_or(Vec3::ZERO); - let _ = app.session.execute("component.revert_position", &json!({})); + let baseline = m.baseline.or_else(|| app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.transform)); let _ = app.run( "occurrence.move", - json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }), + if m.path.is_empty() { + json!({ "occurrence": m.occurrence, "base_transform": baseline, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }) + } else { + json!({ "occurrence": m.occurrence, "path": m.path, "space": "world", "base_transform": baseline, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }) + }, ); } @@ -1617,46 +1816,3 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option = vec!["XY".into(), "XZ".into(), "YZ".into()]; - planes.extend(app.session.doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })).map(|f| f.name.clone())); - let face = app.session.selection.iter().find_map(|s| if let Sel::Face { point, .. } = s { Some(*point) } else { None }); - let mut open = true; - let mut pick: Option = None; - crate::frame::window(ctx, format!("Redefine Sketch Plane: {name}"), crate::frame::Width::Normal) - .id(egui::Id::new("sc_redefine")) - .pivot(egui::Align2::LEFT_TOP) - .default_pos(panel_pos(app)) - .open(&mut open) - .resizable(false) - .collapsible(false) - .show(ctx, |ui| { - ui.label(RichText::new("Move the sketch to").color(Tokens::get().text_dim)); - ui.horizontal_wrapped(|ui| { - for p in &planes { - if ui.button(p).clicked() { - pick = Some(json!(p)); - } - } - }); - ui.add_space(4.0); - let b = ui.add_enabled(face.is_some(), egui::Button::new("Selected face")); - if b.clicked() - && let Some(f) = face - { - pick = Some(json!({ "face": [f.x, f.y, f.z] })); - } - b.on_disabled_hover_text("Select a planar face in the viewport first"); - }); - if let Some(plane) = pick { - if app.run("sketch.redefine", json!({ "sketch": id, "plane": plane })).is_ok() { - app.tree.redefine = None; - } - } else if !open { - app.tree.redefine = None; - } -} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ee171eb3..023632b8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -23,8 +23,26 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { let (at, normal) = match first { Sel::Profile { sketch, index } => { let ss = st.sketch(*sketch)?; + let plane = profile_plane(app, *sketch)?; let p = ss.profiles.get(*index)?; - (ss.plane.to_world(p.region.interior_point()), Some(ss.plane.normal())) + let centre = if matches!(d.kind, Kind::Extrude { .. }) { + let regions: Vec<_> = d + .inputs + .first()? + .items + .iter() + .filter_map(|item| { + if let Sel::Profile { sketch: id, index } = item { (*id == *sketch).then(|| ss.profiles.get(*index)).flatten() } else { None } + }) + .map(|p| p.region.clone()) + .collect(); + let regions = solvecraft_engine::sketch::merge_regions(®ions); + let points: Vec<_> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); + points.iter().fold(solvecraft_engine::geom::Vec2::default(), |sum, p| sum + *p) * (1.0 / points.len().max(1) as f64) + } else { + p.region.interior_point() + }; + (plane.to_world(centre), Some(plane.normal())) } Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)), Sel::Edge { body, index, point } => (*point, st.body(body).and_then(|b| edge_bisector(&b.mesh(), *index, *point))), @@ -43,13 +61,15 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { } _ => return None, }; - Some(match d.kind { - Kind::Extrude { .. } - | Kind::Fillet { .. } - | Kind::Section { .. } - | Kind::Hole { .. } - | Kind::OffsetPlane { .. } - | Kind::OffsetFaces { .. } => (at, normal), + Some(match &d.kind { + Kind::Extrude { start, .. } => { + let n = normal?; + let plane = solvecraft_engine::geom::Plane::from_normal(at, n)?; + let from = reference_height(app, d, "From Object", &plane, at).unwrap_or(0.0); + let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? }; + (at + n * (from + offset), Some(n)) + } + Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), // Shell thickness grows into the body. Kind::Shell { .. } => (at, normal.map(|n| -n)), Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), @@ -59,6 +79,25 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { }) } +fn profile_plane(app: &SolveApp, sketch: u64) -> Option { + let component = app.session.doc.feature(sketch)?.component; + let state = app.session.model.state(); + let plane = state.sketch(sketch)?.plane; + let frame = crate::dialogs::feature_frame(&app.session, component); + solvecraft_engine::geom::Plane::new( + solvecraft_engine::doc::apply_point(&frame, plane.origin), + solvecraft_engine::doc::apply_vector(&frame, plane.x), + solvecraft_engine::doc::apply_vector(&frame, plane.y), + ) +} + +fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_engine::geom::Plane, centre: Vec3) -> Option { + let point = crate::dialogs::object_point(&d.inputs, label)?; + let p = point.as_array()?; + let p = Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?); + Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p)) +} + /// The outward direction between the two faces at an edge (where a fillet's radius arrow /// points). fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { @@ -155,22 +194,57 @@ fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option> #[allow(clippy::too_many_arguments)] fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option) { let t = Tokens::get(); - let sign = match d.kind { - Kind::Extrude { direction: 1, .. } => -1.0, + let to_span = dir.and_then(|n| { + let plane = solvecraft_engine::geom::Plane::from_normal(base, n)?; + reference_height(app, d, "To Object", &plane, base) + }); + let all_span = if matches!(d.kind, Kind::Extrude { all: true, .. }) { + dir.and_then(|n| { + let state = app.session.world_state(); + let plane = match d.inputs.first()?.items.first()? { + Sel::Profile { sketch, .. } => profile_plane(app, *sketch)?, + _ => solvecraft_engine::geom::Plane::from_normal(base, n)?, + }; + let targets: Vec = d + .extra + .get("targets") + .and_then(serde_json::Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(); + solvecraft_engine::doc::through_all_distance(&state, &plane, &targets).ok() + }) + } else { + None + }; + let sign = match (to_span, &d.kind) { + (Some(span), _) => { + if span < 0.0 { + -1.0 + } else { + 1.0 + } + } + (_, Kind::Extrude { direction: 1, .. }) => -1.0, _ => 1.0, }; - let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. }); + let symmetric = to_span.is_none() && matches!(d.kind, Kind::Extrude { direction: 2, .. }); // Extrudes and moves go either way; radii and thicknesses stay positive. let signed = matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. } | Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. }); let focus = std::mem::take(&mut d.focus); let half_height = app.cam.half_height(); let axis = revolve_axis(app, d); + if matches!(d.kind, Kind::Revolve { .. }) { + if let Some(axis) = axis { + draw_revolve(app, ui, painter, proj, d, base, bs, axis, focus); + } + return; + } // Two sides: the second distance gets its own arrow the other way. if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir) && let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite()) { - drag_arrow(ui, painter, proj, base, bs, -n, l2, false, egui::Id::new("sc_manipulator2"), half_height, distance2); + drag_arrow(ui, painter, proj, base, bs, -n, l2, true, egui::Id::new("sc_manipulator2"), half_height, distance2); } // An offset plane shows where it will be. if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir) @@ -184,16 +258,21 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, let mut dir = dir; // Extrude taper: a handle beside the arrow tip; dragging it sideways tilts the walls. let cam_right = app.cam.basis().0; - if let (Kind::Extrude { direction: 0 | 1, distance, taper, .. }, Some(n)) = (&mut d.kind, dir) { + if let (Kind::Extrude { direction: 0 | 1, distance, taper, taper_present, to_offset, .. }, Some(n)) = (&mut d.kind, dir) { let n = n * sign; - if let Ok(l) = app.session.doc.eval(distance, ValueKind::Length) + let span = to_span.map(|span| span.abs() + app.session.doc.eval(to_offset, ValueKind::Length).unwrap_or(0.0)).or(all_span); + if let Ok(l) = span.map(Ok).unwrap_or_else(|| app.session.doc.eval(distance, ValueKind::Length)) && l.is_finite() && l.abs() > 1e-9 && let Some(e) = (cam_right - n * cam_right.dot(n)).normalized() { let px = 2.0 * half_height / f64::from(proj.rect.height().max(1.0)); let ang = app.session.doc.eval(taper, ValueKind::Angle).ok().filter(|v| v.is_finite()).unwrap_or(0.0); + let before = taper.clone(); taper_handle(ui, painter, proj, base - e * (40.0 * px), n * l, -e, ang, taper); + if *taper != before { + *taper_present = true; + } } } if let (Kind::Hole { depth, opts, .. }, Some(n)) = (&mut d.kind, dir) { @@ -207,16 +286,24 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, dir = None; } let Some((label, kind, value)) = d.primary() else { return }; - let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()); + let len = all_span.or_else(|| { + if to_span.is_some() && value.trim().is_empty() { Some(0.0) } else { app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()) } + }); + let before = value.clone(); let mut box_at = bs + vec2(16.0, -30.0); - if let (Some(axis), Some(angle)) = (axis, len.filter(|_| kind == ValueKind::Angle)) - && let Some(at) = rotator(ui, painter, proj, base, axis, angle, value) - { - box_at = at + vec2(14.0, -12.0); - } if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) { let n = n * sign; - if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l, signed, egui::Id::new("sc_manipulator"), half_height, value) { + let baseline = to_span.map(f64::abs).unwrap_or(0.0); + let mut shown_value = format_len(l + baseline); + let original_shown = shown_value.clone(); + let dragged = if to_span.is_some() { &mut shown_value } else { &mut *value }; + if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l + baseline, signed, egui::Id::new("sc_manipulator"), half_height, dragged) { + if to_span.is_some() + && shown_value != original_shown + && let Ok(v) = app.session.doc.eval(&shown_value, ValueKind::Length) + { + *value = format_len(v - baseline); + } if symmetric && let Some(bt) = proj.to_screen(base - n * l) { arrow(painter, bs, bt, t.manipulator); } @@ -241,6 +328,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, }); }); }); + if *value != before + && let Kind::Extrude { all, .. } = &mut d.kind + { + *all = false; + } } /// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes @@ -338,63 +430,341 @@ fn taper_handle(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, foot: V } /// The revolve axis (a point on it and its unit direction). -fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> { +pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> { if !matches!(d.kind, Kind::Revolve { .. }) { return None; } - crate::dialogs::axis_of(app, d.inputs.get(1)?.items.first()?) + if let Some(selected) = d.inputs.get(1)?.items.first() { + if let Sel::Axis { name } = selected + && matches!(name.as_str(), "X" | "Y" | "Z") + && let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? + { + let component = app.session.doc.feature(*sketch)?.component; + let frame = crate::dialogs::feature_frame(&app.session, component); + let dir = match name.as_str() { + "X" => Vec3::X, + "Y" => Vec3::Y, + _ => Vec3::Z, + }; + return Some((solvecraft_engine::doc::apply_point(&frame, Vec3::ZERO), solvecraft_engine::doc::apply_vector(&frame, dir))); + } + if let Sel::Axis { name } = selected + && matches!(name.as_str(), "x" | "y") + && let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? + { + let plane = profile_plane(app, *sketch)?; + return Some((plane.origin, if name == "x" { plane.x } else { plane.y })); + } + return crate::dialogs::axis_of(app, selected); + } + let Kind::Revolve { axis: Some(axis), .. } = &d.kind else { return None }; + let vector = |key: &str| -> Option { + let p = axis.get(key)?.as_array()?; + Some(Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?)) + }; + Some((vector("origin")?, vector("dir")?.normalized()?)) } -/// The revolve angle rotator: an arc from the profile around the axis with a handle at its end -/// that drags the angle (5° steps; Alt or Ctrl: free). Returns the handle's screen position. +/// Draw angular extents in the same clockwise sense as the modeled solid. #[allow(clippy::too_many_arguments)] -fn rotator(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, p0: Vec3, (o, a): (Vec3, Vec3), angle: f64, value: &mut String) -> Option { - let t = Tokens::get(); - let c = o + a * (p0 - o).dot(a); - let r = (p0 - c).len(); - if r < 1e-9 { - return None; +fn draw_revolve( + app: &SolveApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + proj: &Proj, + d: &mut Dialog, + base: Vec3, + bs: Pos2, + axis: (Vec3, Vec3), + focus: bool, +) { + let to_mode = d.inputs.iter().any(|input| input.label == "To Object"); + let extent = revolve_display_extent(app, d, axis).unwrap_or(solvecraft_engine::doc::RevolveExtent { + start: 0.0, + sweep: f64::NAN, + rotation_axis: axis.1, + display_start: 0.0, + curved_to: false, + }); + let mut box_at = bs + vec2(16.0, -30.0); + if !extent.curved_to && extent.sweep.is_finite() { + let (origin, direction) = axis; + let centre = origin + direction * (base - origin).dot(direction); + let radius = (base - centre).len(); + if radius > 1e-9 { + let e1 = (base - centre) / radius; + let e2 = extent.rotation_axis.cross(e1); + let handed = match revolve_convention(d) { + solvecraft_engine::doc::RevolveConvention::LegacyAxisStart => -direction.dot(extent.rotation_axis), + solvecraft_engine::doc::RevolveConvention::SketchNormal => 1.0, + }; + let at = |angle: f64| centre + (e1 * angle.cos() + e2 * angle.sin()) * radius; + let points: Vec<_> = (0..=48).filter_map(|i| proj.to_screen(at(extent.display_start + extent.sweep * i as f64 / 48.0))).collect(); + painter.add(egui::Shape::line(points, Stroke::new(2.0, Tokens::get().manipulator))); + if let Some(end) = proj.to_screen(at(extent.display_start + extent.sweep)) { + box_at = end + vec2(14.0, -12.0); + } + if !to_mode { + let symmetric = !d.extra.contains_key("angle2") && d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"); + let other_angle = d + .extra + .get("angle2") + .and_then(|value| value.as_str()) + .and_then(|value| app.session.doc.eval(value, ValueKind::Angle).ok()) + .unwrap_or(0.0); + let range = if extent.sweep < 0.0 { (-std::f64::consts::TAU, 0.0) } else { (0.0, std::f64::consts::TAU) }; + if let Kind::Revolve { angle, .. } = &mut d.kind { + let boundary = extent.display_start + extent.sweep; + if let Some(end) = rotation_handle( + ui, + painter, + proj, + centre, + e1, + e2, + direction, + radius, + boundary, + if symmetric { 1.0 / (1.0 - handed * 0.5) } else { 1.0 }, + if symmetric { 0.0 } else { -other_angle * (1.0 - handed) }, + (range.0 - other_angle, range.1 - other_angle), + "revolve", + angle, + ) { + box_at = end + vec2(14.0, -12.0); + } + if symmetric { + rotation_handle( + ui, + painter, + proj, + centre, + e1, + e2, + direction, + radius, + extent.display_start, + -2.0 * handed, + 0.0, + range, + "revolve2", + angle, + ); + } + } + if let Some(other) = d.extra.get_mut("angle2") { + let mut value = other.as_str().map(str::to_owned).unwrap_or_else(|| other.to_string()); + let before = value.clone(); + let first_angle = extent.sweep - other_angle; + rotation_handle( + ui, + painter, + proj, + centre, + e1, + e2, + direction, + radius, + extent.display_start, + -handed, + 0.0, + (range.0 - first_angle, range.1 - first_angle), + "revolve2", + &mut value, + ); + if value != before { + *other = serde_json::Value::String(value); + } + } + } + } } - let e1 = (p0 - c) / r; - let e2 = a.cross(e1); - let at = |th: f64| c + (e1 * th.cos() + e2 * th.sin()) * r; - let n = 48; - let pts: Vec = (0..=n).filter_map(|i| proj.to_screen(at(angle * i as f64 / n as f64))).collect(); - let end = proj.to_screen(at(angle))?; - let handle = egui::Rect::from_center_size(end, vec2(20.0, 20.0)); - let resp = ui.interact(handle, egui::Id::new("sc_rotator"), egui::Sense::drag()); - let hot = resp.hovered() || resp.dragged(); - let col = if hot { t.accent } else { t.manipulator }; - painter.add(egui::Shape::line(pts, Stroke::new(2.0, col))); - if let (Some(cs), Some(ps)) = (proj.to_screen(c), proj.to_screen(p0)) { - painter.add(egui::Shape::dashed_line(&[cs, ps], Stroke::new(1.0, col), 4.0, 3.0)); - } - painter.circle(end, if hot { 7.0 } else { 5.5 }, col, Stroke::new(1.5, Color32::WHITE)); + egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + if to_mode { + let text = if extent.curved_to { + "To surface".into() + } else if extent.sweep.is_finite() { + format!("Sweep {:.1}°", extent.sweep.to_degrees()) + } else { + "Choose target".into() + }; + ui.label(text).on_hover_text("Determined by To Object. Choose Angle to edit the angular extent."); + } else if let Kind::Revolve { angle, .. } = &mut d.kind { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Angle").color(Tokens::get().text_dim).size(11.5)); + let id = egui::Id::new("sc_canvas_value"); + let response = ui.add(egui::TextEdit::singleline(angle).id(id).desired_width(72.0)); + crate::params_dialog::complete(ui, &response, angle); + if focus { + response.request_focus(); + let n = angle.chars().count(); + let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); + state + .cursor + .set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + state.store(ui.ctx(), id); + } + }); + } + }); + }); +} + +fn revolve_convention(d: &Dialog) -> solvecraft_engine::doc::RevolveConvention { + d.extra + .get("convention") + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or(solvecraft_engine::doc::RevolveConvention::SketchNormal) +} + +fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Option { + let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? else { return None }; + let state = app.session.world_state(); + let ss = state.sketch(*sketch)?; + let regions: Vec<_> = d + .inputs + .first()? + .items + .iter() + .filter_map(|item| match item { + Sel::Profile { sketch: id, index } if id == sketch => ss.profiles.get(*index).map(|profile| profile.region.clone()), + _ => None, + }) + .collect(); + let regions = solvecraft_engine::sketch::merge_regions(®ions); + let plane = profile_plane(app, *sketch)?; + let to_mode = d.inputs.iter().any(|input| input.label == "To Object"); + let to = if to_mode { + let point = crate::dialogs::object_point(&d.inputs, "To Object")?; + Some(Vec3::new(point.get(0)?.as_f64()?, point.get(1)?.as_f64()?, point.get(2)?.as_f64()?)) + } else { + None + }; + let Kind::Revolve { angle, .. } = &d.kind else { return None }; + let angles = if to_mode { + (0.0, None) + } else { + let angle = app.session.doc.eval(angle, ValueKind::Angle).ok()?; + let other = d.extra.get("angle2").map(|value| app.session.doc.eval(value.as_str().unwrap_or_default(), ValueKind::Angle)).transpose().ok()?; + (angle, other) + }; + solvecraft_engine::doc::revolve_extent_with_convention( + &state, + &plane, + ®ions, + axis, + angles, + d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"), + to, + revolve_convention(d), + ) + .ok() +} + +#[allow(clippy::too_many_arguments)] +fn rotation_handle( + ui: &mut egui::Ui, + painter: &egui::Painter, + proj: &Proj, + centre: Vec3, + e1: Vec3, + e2: Vec3, + axis: Vec3, + radius: f64, + boundary: f64, + scale: f64, + offset: f64, + range: (f64, f64), + name: &str, + value: &mut String, +) -> Option { + let end = proj.to_screen(centre + (e1 * boundary.cos() + e2 * boundary.sin()) * radius)?; + crate::scenario::publish_handle(name, end); + let response = ui.interact(egui::Rect::from_center_size(end, vec2(20.0, 20.0)), egui::Id::new(("sc_rotator", name)), egui::Sense::drag()); + let hot = response.hovered() || response.dragged(); + let color = if hot { Tokens::get().accent } else { Tokens::get().manipulator }; + painter.circle(end, if hot { 7.0 } else { 5.5 }, color, Stroke::new(1.5, Color32::WHITE)); if hot { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } - if resp.dragged() - && let Some(p) = resp.interact_pointer_pos() + if response.dragged() + && let Some(pointer) = response.interact_pointer_pos() { - let (ro, rd) = proj.ray(p); - let den = rd.dot(a); - if den.abs() > 1e-9 { - let q = ro + rd * ((c - ro).dot(a) / den) - c; - let mut th = q.dot(e2).atan2(q.dot(e1)).to_degrees().rem_euclid(360.0); - let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command); - if !free { - th = (th / 5.0).round() * 5.0; + let (origin, direction) = proj.ray(pointer); + let den = direction.dot(axis); + let (a, b) = ((range.0 - offset) / scale, (range.1 - offset) / scale); + let bounds = (a.min(b), a.max(b)); + // Near edge-on, a plane intersection magnifies subpixel pointer error. + let raw = if den.abs() > 0.05 { + let point = origin + direction * ((centre - origin).dot(axis) / den) - centre; + Some(point.dot(e2).atan2(point.dot(e1))) + } else { + projected_rotation_angle(proj, pointer, centre, e1, e2, radius, boundary, bounds) + }; + if let Some(raw) = raw { + let boundary_angle = (-4..=4) + .map(|k| raw + k as f64 * std::f64::consts::TAU) + .filter(|angle| *angle >= bounds.0 - 1e-4 && *angle <= bounds.1 + 1e-4) + .min_by(|a, b| (a - boundary).abs().total_cmp(&(b - boundary).abs())) + .unwrap_or_else(|| raw.clamp(bounds.0, bounds.1)); + let mut angle = (boundary_angle.clamp(bounds.0, bounds.1) * scale + offset).to_degrees(); + if !ui.input(|input| input.modifiers.alt || input.modifiers.ctrl || input.modifiers.command) { + angle = (angle / 5.0).round() * 5.0; } - if th < 0.5 { - th = 360.0; + if angle.abs() < 0.05 { + angle = 0.0; } - let s = format!("{th:.1}"); - *value = format!("{} deg", s.trim_end_matches('0').trim_end_matches('.')); + let text = format!("{angle:.1}"); + *value = format!("{} deg", text.trim_end_matches('0').trim_end_matches('.')); } } Some(end) } +/// An edge-on ring has two equally valid depths; retain the branch nearest the current handle. +#[allow(clippy::too_many_arguments)] +fn projected_rotation_angle( + proj: &Proj, + pointer: Pos2, + centre: Vec3, + e1: Vec3, + e2: Vec3, + radius: f64, + current: f64, + (lo, hi): (f64, f64), +) -> Option { + let distance = |angle: f64| { + proj.to_screen(centre + (e1 * angle.cos() + e2 * angle.sin()) * radius) + .map(|point| f64::from(point.distance_sq(pointer))) + .unwrap_or(f64::INFINITY) + }; + let step = (hi - lo) / 720.0; + let mut best = None; + for i in 0..=720 { + let angle = lo + step * i as f64; + let score = distance(angle); + if !score.is_finite() { + continue; + } + if best.is_none_or(|(old_angle, old_score): (f64, f64)| { + score < old_score - 1e-4 || (score - old_score).abs() <= 1e-4 && (angle - current).abs() < (old_angle - current).abs() + }) { + best = Some((angle, score)); + } + } + let (angle, _) = best?; + let (mut a, mut b) = ((angle - step).max(lo), (angle + step).min(hi)); + for _ in 0..24 { + let (left, right) = (a + (b - a) / 3.0, b - (b - a) / 3.0); + if distance(left) <= distance(right) { + b = right; + } else { + a = left; + } + } + Some((a + b) / 2.0) +} fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) { let v = b - a; let l = v.length(); diff --git a/crates/ui-egui/src/configuration_rules_ui.rs b/crates/ui-egui/src/configuration_rules_ui.rs new file mode 100644 index 00000000..0fcffab3 --- /dev/null +++ b/crates/ui-egui/src/configuration_rules_ui.rs @@ -0,0 +1,368 @@ +//! Structured configuration rule editing and non-mutating row preview. +use crate::SolveApp; +use egui::{RichText, Ui}; +use serde_json::{Value, json}; +use solvecraft_engine::doc::config::{Column, ConfigRule, RuleCondition, RuleEffect, RuleOperator}; + +#[derive(Clone, Debug)] +pub struct Editor { + rule: ConfigRule, + row: String, + preview: Option, + + preview_revision: u64, + pub(super) drafts: std::collections::BTreeMap<(String, String), String>, + pub(super) table: Option<(u64, Value)>, +} +impl Default for Editor { + fn default() -> Self { + Self { + rule: ConfigRule { + id: String::new(), + name: "New rule".into(), + enabled: true, + length_unit: None, + condition: RuleCondition::Always, + effect: RuleEffect::DriveParameter { parameter: String::new(), expression: "10 mm".into() }, + }, + row: String::new(), + preview: None, + + preview_revision: 0, + drafts: Default::default(), + table: None, + } + } +} +fn choose(ui: &mut Ui, id: &str, value: &mut String, options: &[String]) { + egui::ComboBox::from_id_salt(id).selected_text(if value.is_empty() { "Choose…" } else { value.as_str() }).width(130.0).show_ui(ui, |ui| { + for option in options { + ui.selectable_value(value, option.clone(), option); + } + }); +} +fn expression(ui: &mut Ui, label: &str, value: &mut String) { + ui.label(label); + ui.add(egui::TextEdit::singleline(value).desired_width(160.0)); +} + +pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option, editor: &mut Editor) { + if editor.preview_revision != app.session.revision { + editor.preview = None; + } + let rules = app.session.doc.configs.rules.clone(); + let parameters: Vec = + app.session.doc.configs.columns.iter().filter_map(|c| if let Column::Param(n) = c { Some(n.clone()) } else { None }).collect(); + let all_parameters: Vec = app.session.doc.all_param_exprs().into_iter().map(|p| p.0).collect(); + let features: Vec<(u64, String)> = app.session.doc.features.iter().map(|f| (f.id, f.name.clone())).collect(); + let suppression_features: Vec<(u64, String)> = + features.iter().filter(|(id, _)| app.session.doc.configs.columns.contains(&Column::Suppress(*id))).cloned().collect(); + ui.separator(); + ui.label(RichText::new("Configuration rules").strong()); + ui.add(egui::Label::new("Rules run from top to bottom. Matched drives change effective values.").wrap()); + ui.add(egui::Label::new("Matched restrictions check the final values.").wrap()); + for (index, rule) in rules.iter().enumerate() { + ui.push_id(&rule.id, |ui| { + ui.horizontal(|ui| { + let mut enabled = rule.enabled; + if ui.checkbox(&mut enabled, "").on_hover_text("Enable rule").changed() { + let mut r = rule.clone(); + r.enabled = enabled; + *error = app.run("config.rule", json!({"rule":r})).err(); + } + ui.add(egui::Label::new(&rule.name).truncate()); + if ui.small_button("Edit").clicked() { + editor.rule = rule.clone(); + } + if ui.add_enabled(index > 0, egui::Button::new("↑").small()).on_hover_text("Move earlier").clicked() { + *error = app.run("config.rule", json!({"id":rule.id,"index":index-1})).err(); + } + if ui.add_enabled(index + 1 < rules.len(), egui::Button::new("↓").small()).on_hover_text("Move later").clicked() { + *error = app.run("config.rule", json!({"id":rule.id,"index":index+1})).err(); + } + if ui.small_button("Delete").clicked() { + *error = app.run("config.rule", json!({"id":rule.id,"delete":true})).err(); + } + }); + ui.label(match &rule.effect { + RuleEffect::DriveParameter { parameter, expression } => format!("Drives {parameter} = {expression}"), + RuleEffect::DriveSuppression { feature, value } => format!( + "Drives suppression {} = {value}", + features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("missing feature") + ), + RuleEffect::ParameterRange { parameter, minimum, maximum } => format!("Restricts {parameter}: {minimum} through {maximum}"), + RuleEffect::AllowParameterValues { parameter, expressions } => format!("Restricts {parameter}: {}", expressions.join(", ")), + }); + }); + } + ui.collapsing("Add or edit rule", |ui| { + ui.horizontal(|ui| { + expression(ui, "Rule name", &mut editor.rule.name); + ui.checkbox(&mut editor.rule.enabled, "Enabled"); + if ui.small_button("New").clicked() { + editor.rule = Editor::default().rule; + } + }); + let mut length_unit = editor.rule.length_unit.clone().unwrap_or_else(|| app.session.doc.units.clone()); + let old_unit = length_unit.clone(); + ui.horizontal(|ui| { + ui.label("Length units"); + choose(ui, "rule_length_units", &mut length_unit, &["mm".into(), "cm".into(), "m".into(), "in".into(), "ft".into()]); + }); + if length_unit != old_unit { + editor.rule.length_unit = Some(length_unit); + } + condition_editor(ui, &mut editor.rule.condition, &all_parameters, &features, 1); + let mut effect_kind = match editor.rule.effect { + RuleEffect::DriveParameter { .. } => "Drive parameter", + RuleEffect::DriveSuppression { .. } => "Drive suppression", + RuleEffect::ParameterRange { .. } => "Range", + RuleEffect::AllowParameterValues { .. } => "Allowed values", + } + .to_string(); + let prior = effect_kind.clone(); + ui.horizontal(|ui| { + ui.label("Then"); + choose( + ui, + "rule_effect_kind", + &mut effect_kind, + &["Drive parameter".into(), "Drive suppression".into(), "Range".into(), "Allowed values".into()], + ); + }); + if prior != effect_kind { + let parameter = parameters.first().cloned().unwrap_or_default(); + editor.rule.effect = match effect_kind.as_str() { + "Drive suppression" => RuleEffect::DriveSuppression { feature: suppression_features.first().map(|f| f.0).unwrap_or(0), value: true }, + "Range" => RuleEffect::ParameterRange { parameter, minimum: "0 mm".into(), maximum: "100 mm".into() }, + "Allowed values" => RuleEffect::AllowParameterValues { parameter, expressions: vec!["10 mm".into(), "20 mm".into()] }, + _ => RuleEffect::DriveParameter { parameter, expression: "10 mm".into() }, + }; + } + match &mut editor.rule.effect { + RuleEffect::DriveParameter { parameter, expression: value } => { + ui.horizontal(|ui| { + choose(ui, "effect_parameter", parameter, ¶meters); + expression(ui, "Value", value); + }); + } + RuleEffect::DriveSuppression { feature, value } => { + ui.horizontal(|ui| { + feature_choice(ui, "effect_feature", feature, &suppression_features); + ui.checkbox(value, "Suppressed"); + }); + } + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + choose(ui, "range_parameter", parameter, ¶meters); + ui.horizontal(|ui| { + expression(ui, "Minimum", minimum); + }); + ui.horizontal(|ui| { + expression(ui, "Maximum", maximum); + }); + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + choose(ui, "allowed_parameter", parameter, ¶meters); + let mut text = expressions.join("; "); + ui.label("Allowed expressions, separated by semicolons"); + if ui.add(egui::TextEdit::singleline(&mut text).desired_width(300.0)).changed() { + *expressions = text.split(';').map(|s| s.trim().to_string()).collect(); + } + } + } + if ui.add_enabled(!app.session.doc.configs.rows.is_empty(), egui::Button::new("Save Rule")).clicked() { + let result = (|| -> Result<(), String> { + let mut rule = editor.rule.clone(); + if rule.id.is_empty() { + rule.id = (1..).map(|n| format!("Rule{n}")).find(|id| !rules.iter().any(|r| r.id == *id)).unwrap_or_default(); + } + app.run("config.rule", json!({"rule":rule.clone()}))?; + editor.rule = app.session.doc.configs.rules.iter().find(|existing| existing.id == rule.id).cloned().unwrap_or(rule); + Ok(()) + })(); + *error = result.err(); + } + }); + let rows: Vec = app.session.doc.configs.rows.iter().map(|r| r.name.clone()).collect(); + if !rows.contains(&editor.row) { + editor.row = app.session.doc.configs.active.clone().or_else(|| rows.first().cloned()).unwrap_or_default(); + } + let previous_row = editor.row.clone(); + ui.horizontal(|ui| { + choose(ui, "preview_configuration", &mut editor.row, &rows); + if ui.add_enabled(!editor.row.is_empty(), egui::Button::new("Preview Row")).clicked() { + match app.session.execute("config.preview", &json!({"row":editor.row})) { + Ok(result) => { + editor.preview = Some(result); + editor.preview_revision = app.session.revision; + *error = None; + } + Err(e) => { + editor.preview = None; + *error = Some(e.to_string()); + } + } + } + }); + if previous_row != editor.row { + editor.preview = None; + } + if let Some(preview) = &editor.preview { + ui.collapsing(format!("Preview: {}", preview["active"].as_str().unwrap_or("row")), |ui| { + ui.label("Preview leaves the current design unchanged."); + if let Some(styles) = preview["styles"].as_array() { + for style in styles { + ui.add( + egui::Label::new(format!( + "{}: {} (shared definition)", + style["target"].as_str().unwrap_or("Appearance"), + if style["dormant"].as_bool() == Some(true) { "dormant" } else { "resolved" } + )) + .wrap(), + ); + } + } + if let Some(trace) = preview["trace"].as_array() { + for entry in trace { + ui.label(format!( + "{} — {}", + entry["name"].as_str().unwrap_or("rule"), + if entry["matched"].as_bool() == Some(true) { "matched" } else { "skipped" } + )); + if entry["matched"].as_bool() == Some(true) + && let Ok(effect) = serde_json::from_value::(entry["effect"].clone()) + { + ui.label(match effect { + RuleEffect::DriveParameter { parameter, expression } => format!("Driven: {parameter} = {expression}"), + RuleEffect::DriveSuppression { feature, value } => format!( + "Driven: {} is {}", + features.iter().find(|f| f.0 == feature).map(|f| f.1.as_str()).unwrap_or("feature"), + if value { "suppressed" } else { "unsuppressed" } + ), + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + format!("Restricted: {parameter}, {minimum} through {maximum}") + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + format!("Restricted: {parameter}, {}", expressions.join("; ")) + } + }); + } + } + } + if let Some(parameters) = preview["parameters"].as_array() { + for parameter in parameters { + if let Some(name) = parameter["name"].as_str() + && all_parameters.iter().any(|p| p == name) + { + ui.label(format!("{name} = {}", parameter["expression"].as_str().unwrap_or(""))); + } + } + } + }); + } +} +fn feature_choice(ui: &mut Ui, id: &str, feature: &mut u64, features: &[(u64, String)]) { + egui::ComboBox::from_id_salt(id) + .selected_text(features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("Choose feature…")) + .show_ui(ui, |ui| { + for (id, name) in features { + ui.selectable_value(feature, *id, name); + } + }); +} + +fn condition_editor(ui: &mut Ui, condition: &mut RuleCondition, parameters: &[String], features: &[(u64, String)], depth: usize) { + let mut kind = match condition { + RuleCondition::Always => "Always", + RuleCondition::Parameter { .. } => "Parameter", + RuleCondition::Suppression { .. } => "Suppression", + RuleCondition::All { .. } => "All", + RuleCondition::Any { .. } => "Any", + RuleCondition::Not { .. } => "Not", + } + .to_string(); + let previous = kind.clone(); + let mut choices = vec!["Always".into(), "Parameter".into(), "Suppression".into()]; + if depth < 8 { + choices.extend(["All".into(), "Any".into(), "Not".into()]); + } + ui.horizontal(|ui| { + ui.label("When"); + choose(ui, "condition_kind", &mut kind, &choices); + }); + if previous != kind { + *condition = match kind.as_str() { + "Parameter" => RuleCondition::Parameter { + parameter: parameters.first().cloned().unwrap_or_default(), + operator: RuleOperator::Ge, + expression: "10 mm".into(), + }, + "Suppression" => RuleCondition::Suppression { feature: features.first().map(|f| f.0).unwrap_or(0), value: true }, + "All" => RuleCondition::All { conditions: vec![RuleCondition::Always] }, + "Any" => RuleCondition::Any { conditions: vec![RuleCondition::Always] }, + "Not" => RuleCondition::Not { condition: Box::new(RuleCondition::Always) }, + _ => RuleCondition::Always, + }; + } + match condition { + RuleCondition::Parameter { parameter, operator, expression } => { + ui.horizontal(|ui| { + choose(ui, "condition_parameter", parameter, parameters); + egui::ComboBox::from_id_salt("condition_operator") + .width(45.0) + .selected_text(match operator { + RuleOperator::Eq => "=", + RuleOperator::Ne => "≠", + RuleOperator::Lt => "<", + RuleOperator::Le => "≤", + RuleOperator::Gt => ">", + RuleOperator::Ge => "≥", + }) + .show_ui(ui, |ui| { + for (label, op) in [ + ("=", RuleOperator::Eq), + ("≠", RuleOperator::Ne), + ("<", RuleOperator::Lt), + ("≤", RuleOperator::Le), + (">", RuleOperator::Gt), + ("≥", RuleOperator::Ge), + ] { + ui.selectable_value(operator, op, label); + } + }); + ui.add(egui::TextEdit::singleline(expression).desired_width(110.0)); + }); + } + RuleCondition::Suppression { feature, value } => { + ui.horizontal(|ui| { + feature_choice(ui, "condition_feature", feature, features); + ui.checkbox(value, "Suppressed"); + }); + } + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => { + let mut remove = None; + for (index, child) in conditions.iter_mut().enumerate() { + ui.push_id(index, |ui| { + ui.indent("condition_group", |ui| { + condition_editor(ui, child, parameters, features, depth + 1); + if ui.small_button("Remove condition").clicked() { + remove = Some(index); + } + }); + }); + } + if let Some(index) = remove { + conditions.remove(index); + } + if ui.add_enabled(conditions.len() < 32, egui::Button::new("Add condition").small()).clicked() { + conditions.push(RuleCondition::Always); + } + } + RuleCondition::Not { condition } => { + ui.indent("negated_condition", |ui| { + condition_editor(ui, condition, parameters, features, depth + 1); + }); + } + RuleCondition::Always => {} + } +} diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 3b9ba9ab..e22b126d 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -34,6 +34,9 @@ pub enum Target { Component { id: u64, }, + Occurrence { + path: Vec, + }, /// A browser group of items. Group { id: u64, @@ -227,6 +230,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { Target::Viewport => touched_bodies(app), Target::Body { name } => vec![name.clone()], Target::Component { id } => component_bodies(app, *id), + Target::Occurrence { path } => app.session.occurrence_bodies(path, true).unwrap_or_default(), Target::Group { id } => crate::browser::group_bodies(app, *id), Target::Sketch { .. } | Target::Folder { .. } @@ -244,14 +248,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { /// Bodies in a component and its subcomponents. pub fn component_bodies(app: &SolveApp, id: u64) -> Vec { let doc = &app.session.doc; - app.session - .model - .state() - .bodies - .iter() - .filter(|b| doc.component_within(doc.body_component(&b.name, b.feature), id)) - .map(|b| b.name.clone()) - .collect() + app.session.model.state().bodies.iter().filter(|b| doc.component_within(doc.model_body_component(b), id)).map(|b| b.name.clone()).collect() } /// The eight radial commands, clockwise from the top. @@ -292,6 +289,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Body { name } => body_items(app, std::slice::from_ref(name), true), Target::Sketch { id } => sketch_items(app, *id), Target::Component { id } => component_items(app, *id), + Target::Occurrence { path } => occurrence_items(app, path), Target::Group { id } => crate::browser::group_items(app, *id), Target::Folder { component, folder } => crate::browser::folder_items(app, *component, folder), Target::Canvas { id } => crate::browser::canvas_items(app, *id), @@ -366,7 +364,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 +435,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 })), @@ -504,6 +502,54 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec { ] } +fn occurrence_items(app: &SolveApp, path: &[u64]) -> Vec { + let Ok(placement) = app.session.occurrence_placement(path) else { return Vec::new() }; + let mut entries = component_items(app, placement.component); + if app.session.occurrence_scope_count(path).is_ok_and(|n| n > 1) { + entries.insert(0, Item::heading("Shared child: edits affect all copies of its parent")); + } + let bodies = app.session.occurrence_bodies(path, true).unwrap_or_default(); + for item in &mut entries { + match item.id.as_str() { + "ui.activate" => { + item.params = Some(json!({"component": placement.component, "occurrence": placement.occurrence, "path": path})); + item.enabled = app.session.active_occurrence_path.as_deref() != Some(path); + } + "occurrence.ground" => { + item.label = if placement.grounded { "Unground" } else { "Ground" }.into(); + item.params = Some(json!({"occurrence": placement.occurrence, "grounded": !placement.grounded})); + } + "ui.moveOccurrence" => { + item.params = Some(json!({"occurrence": placement.occurrence, "path": path})); + item.enabled = !placement.grounded; + } + "ui.properties" => item.params = Some(json!({"occurrence": placement.occurrence, "path": path})), + "occurrence.copy" => item.params = Some(json!({"occurrence": placement.occurrence, "translate": [10,10,0]})), + "component.create" => item.params = Some(json!({"parent": placement.component, "parent_path": path})), + "component.paste_new" => { + item.label = "Paste New (Own Features)".into(); + item.params = Some(json!({"component":placement.component,"occurrence":placement.occurrence,"path":path,"translate":[10,10,0]})); + } + "ui.rename" => item.label = "Rename Component".into(), + "ui.appearance" => item.label = "Component Appearance".into(), + "ui.material" => item.label = "Component Physical Material".into(), + "ui.delete" => item.params = Some(json!({"occurrences": [placement.occurrence]})), + "ui.hide" | "ui.show" => { + item.id = "browser.visibility".into(); + item.params = Some(json!({"visible": app.session.occurrence_hidden(path), "paths": [path]})); + item.enabled = true; + } + "ui.isolate" => { + item.id = "ui.isolateOccurrence".into(); + item.params = Some(json!({"path": path})); + } + "ui.export" | "ui.saveMesh" => item.params = Some(json!({"bodies": bodies})), + _ => {} + } + } + entries +} + fn component_items(app: &SolveApp, id: u64) -> Vec { let doc = &app.session.doc; let occ = doc.occurrence_of(id); @@ -530,8 +576,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())); @@ -594,6 +645,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { if !curves.is_empty() { v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves }))); } + if curves.len() == 1 && ss.sketch.freeform_offsets.iter().any(|offset| offset.output == curves[0]) { + v.push(cmd(app, "sketch.offset.break", "Break Offset Association").with(json!({"curve":curves[0]}))); + } v.push(act("ui.fix", "Fix/Unfix", "c_fix").with(json!({ "entities": ents }))); v.push(act("ui.delete", "Delete", "delete").key("Del")); v.push(cmd(app, "sketch.dimension", "Sketch Dimension").with(json!({ "entities": ents })).on(ents.len() <= 2)); @@ -676,7 +730,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } "ui.show" => app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)), "ui.showAll" => { - app.session.visibility.hidden_bodies.clear(); + let _ = app.run("browser.visibility", json!({"folder":"bodies", "visible":true})); app.session.visibility.hidden_sketches.clear(); } "ui.isolate" => { @@ -716,10 +770,26 @@ 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("occurrence").is_some() { + "occurrence.properties" + } else if p.get("component").is_some() { + "component.properties" + } else { + "inspect.measure" + }; + let params = if command == "inspect.measure" { json!({ "bodies": bodies }) } else { p.clone() }; + 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"])) @@ -783,15 +853,27 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } } "ui.activate" => { - if let Some(id) = id_of(&p, "component") { - let _ = app.run("component.activate", json!({ "component": id })); + if id_of(&p, "component").is_some() { + let _ = app.run("component.activate", p); } } "ui.moveOccurrence" => { if let Some(o) = id_of(&p, "occurrence") { - app.tree.occurrence_move = Some(crate::browser::OccMove::new(o)); + let mut movement = crate::browser::OccMove::new(o); + movement.had_pending = app.session.pending_moves.contains_key(&o); + movement.path = p.get("path").and_then(Value::as_array).map(|p| p.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + movement.baseline = app + .session + .pending_moves + .get(&o) + .copied() + .or_else(|| app.session.doc.occurrences.iter().find(|occ| occ.id == o).map(|occ| occ.transform)); + app.tree.occurrence_move = Some(movement); } } + "ui.isolateOccurrence" => { + let _ = app.run("browser.visibility", json!({"visible": true, "paths": [p["path"]], "isolate": true})); + } "ui.canvasVisible" => drop(app.run("canvas.edit", p)), "ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p), "ui.workspace" => {} @@ -847,6 +929,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 })); @@ -913,6 +1015,12 @@ pub fn delete(app: &mut SolveApp) { /// axes, components' bodies, canvases). When some are shown and some hidden, all are hidden. pub fn toggle_visibility(app: &mut SolveApp) { use solvecraft_engine::doc::FeatureKind; + let paths: Vec> = + app.session.selection.iter().filter_map(|s| if let Sel::Occurrence { path } = s { Some(path.clone()) } else { None }).collect(); + if !paths.is_empty() { + let visible = !paths.iter().any(|p| !app.session.occurrence_hidden(p)); + let _ = app.run("browser.visibility", json!({"visible": visible, "paths": paths})); + } let mut bodies: Vec = touched_bodies(app); for c in app.tree.picked_components.clone() { bodies.extend(component_bodies(app, c)); @@ -959,6 +1067,10 @@ pub fn rename_selection(app: &mut SolveApp, at: Pos2) -> bool { let what = match (app.session.selection.as_slice(), app.tree.picked_components.as_slice(), app.tree.picked_canvases.as_slice()) { ([Sel::Body { name }], [], []) => RenameWhat::Body(name.clone()), ([Sel::Feature { id }], [], []) => RenameWhat::Feature(*id), + ([Sel::Occurrence { path }], [], []) => match app.session.occurrence_placement(path) { + Ok(p) => RenameWhat::Component(p.component), + Err(_) => return false, + }, ([Sel::Plane { name }], [], []) => match app.session.doc.find_feature(name) { Some(f) => RenameWhat::Feature(f.id), None => return false, @@ -1389,14 +1501,9 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option { return None; } if item.is_heading() { - let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover()); - ui.painter().text( - pos2(r.left() + 8.0, r.center().y + 2.0), - Align2::LEFT_CENTER, - item.label.to_uppercase(), - FontId::proportional(10.5), - t.text_dim, - ); + let text = ui.painter().layout(item.label.to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0); + let (r, _) = ui.allocate_exact_size(vec2(w, (text.size().y + 8.0).max(20.0)), Sense::hover()); + ui.painter().galley(pos2(r.left() + 8.0, r.top() + 4.0), text, t.text_dim); return None; } let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), if item.enabled { Sense::click() } else { Sense::hover() }); @@ -1502,7 +1609,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/control.rs b/crates/ui-egui/src/control.rs index 7e8e2cb0..7b68414a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -303,6 +303,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> ok(json!(list.into_iter().map(|(n, p)| json!({"name": n, "x": p.x, "y": p.y})).collect::>())) } "ui.at" => { + if let Some(path) = p.get("occurrence").and_then(Value::as_array) { + let ids: Option> = path.iter().map(Value::as_u64).collect(); + let Some(ids) = ids else { return err("occurrence path must contain integer ids") }; + let key = ids.iter().map(u64::to_string).collect::>().join("/"); + let control = s("control").unwrap_or("row"); + return match crate::scenario::handle_at(&format!("{control}:occurrence:{key}")) { + Some(q) => ok(json!([q.x, q.y])), + None => err("occurrence row or control is not shown"), + }; + } // A scenario point: {world: [x,y,z]} | {sketch: [x,y]} | {plane: "XY"} (the middle of an // origin plane's square) → screen point. if let Some(h) = p.get("handle").and_then(Value::as_str) { @@ -465,6 +475,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } } "ui.browser" => ok(json!({ + "occurrences": crate::browser::drawn_occurrences().iter().map(|(path, label, r, open)| json!({"path": path, "label": label, "y": r.center().y, "open": open})).collect::>(), "rows": crate::browser::drawn_rows().iter().map(|(label, r, _, open)| json!({"label": label, "y": r.center().y, "open": open})).collect::>(), "selection": app.session.selection, "picked_components": app.tree.picked_components, diff --git a/crates/ui-egui/src/delete.rs b/crates/ui-egui/src/delete.rs index cd4115ea..9089d952 100644 --- a/crates/ui-egui/src/delete.rs +++ b/crates/ui-egui/src/delete.rs @@ -28,6 +28,18 @@ fn is_empty(p: &Value) -> bool { ["items", "occurrences", "components", "canvases"].iter().all(|k| p.get(*k).and_then(Value::as_array).is_none_or(|a| a.is_empty())) } +fn shared_occurrences(app: &SolveApp, params: &Value) -> bool { + let explicit = params.get("occurrences").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64); + let selected = params + .get("items") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| item["type"] == "occurrence") + .filter_map(|item| item["path"].as_array()?.last()?.as_u64()); + explicit.chain(selected).any(|id| app.session.occurrence_id_scope_count(id).is_ok_and(|n| n > 1)) +} + /// Delete what is selected (the Delete key). A selected sketch dimension or constraint glyph /// goes first, as before. pub fn delete_selection(app: &mut SolveApp) { @@ -56,7 +68,8 @@ pub fn request(app: &mut SolveApp, params: Value) { params.get("items").and_then(Value::as_array).map_or(0, |a| a.iter().filter(|i| i["type"] == "feature" || i["type"] == "plane").count()); let extra = report["deleted"].as_array().map_or(0, Vec::len) > asked; let fails = report["would_fail"].as_array().is_some_and(|a| !a.is_empty()); - if extra || fails { + let shared = shared_occurrences(app, ¶ms); + if extra || fails || shared { app.menu.confirm = Some(Pending { params, report }); } else { run(app, ¶ms); @@ -101,6 +114,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -60.0)) .show(ctx, |ui| { ui.set_max_width(360.0); + if shared_occurrences(app, &p.params) { + ui.label("This child is shared. Deleting it removes it from every copy of its parent."); + } if !deleted.is_empty() { ui.label("These features will be deleted:"); for d in deleted.iter().take(20) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5038b79e..1e0500f4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -40,6 +40,10 @@ pub enum Kind { distance2: String, /// Taper angle (empty or zero: none). taper: String, + /// An explicitly authored zero taper is still a model parameter. + taper_present: bool, + /// Optional distance beyond the selected end object. + to_offset: String, /// Start offset from the profile plane (empty: start on the profile plane). start: String, /// Extent type All (through everything) instead of a distance. @@ -48,6 +52,8 @@ pub enum Kind { Revolve { angle: String, operation: usize, + /// An arbitrary stored line until the user deliberately picks a replacement. + axis: Option, }, Fillet { radius: String, @@ -262,6 +268,8 @@ pub struct Dialog { pub error: Option, /// Editing an existing feature (its id, and the timeline marker to restore afterwards). pub editing: Option<(u64, Option)>, + /// A user pick or clear makes the profile selector explicit, even if all are reselected. + pub profile_selection_changed: bool, /// Give the on-canvas value box the keyboard (and select its text) on the next frame. pub focus: bool, /// Folded to its header (the "−" glyph). @@ -282,13 +290,23 @@ fn fits(a: Accept, s: &Sel) -> bool { Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0, Sel::Vertex { .. } => a & selection::VERTICES != 0, Sel::Feature { .. } => a & selection::FEATURES != 0, - Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } => false, + Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } | Sel::Occurrence { .. } => false, } } impl Dialog { pub(crate) fn new(kind: Kind, inputs: Vec) -> Dialog { - Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, collapsed: false, extra: serde_json::Map::new() } + Dialog { + kind, + inputs, + active: 0, + error: None, + editing: None, + profile_selection_changed: false, + focus: true, + collapsed: false, + extra: serde_json::Map::new(), + } } pub fn for_command(app: &SolveApp, id: &str) -> Option { @@ -304,13 +322,15 @@ impl Dialog { auto_op: true, distance2: "10 mm".into(), taper: "0 deg".into(), + taper_present: false, + to_offset: String::new(), start: String::new(), all: false, }, vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)], ), "solid.revolve" => Dialog::new( - Kind::Revolve { angle: "360 deg".into(), operation: 0 }, + Kind::Revolve { angle: "360 deg".into(), operation: 0, axis: None }, vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Axis", AXES, false)], ), "solid.fillet" => Dialog::new(fillet_kind("2 mm", false), vec![SelInput::new("Edges", EDGES | FACES, true)]), @@ -332,7 +352,10 @@ impl Dialog { Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false }, vec![SelInput::new("Curves", CURVES, true)], ), - "solid.emboss" => Dialog::new(Kind::Emboss { depth: "1 mm".into(), deboss: false }, vec![SelInput::new("Profiles", PROFILES, true)]), + "solid.emboss" => Dialog::new( + Kind::Emboss { depth: "1 mm".into(), deboss: false }, + vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Target Face (optional)", FACES, false)], + ), "solid.replace_face" => Dialog::new( Kind::ReplaceFace, vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)], @@ -567,6 +590,7 @@ impl Dialog { /// The main value (shown on the canvas too): label, kind and the expression. pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> { Some(match &mut self.kind { + Kind::Extrude { to_offset, .. } if to_object_on(&self.inputs) => ("To Offset", ValueKind::Length, to_offset), Kind::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance), Kind::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle), Kind::Fillet { radius, chamfer, .. } => (if *chamfer { "Distance" } else { "Radius" }, ValueKind::Length, radius), @@ -633,6 +657,9 @@ impl Dialog { /// A pick in the viewport (already accepted by the active input). pub fn pick(&mut self, s: &Session, sel: Sel) { + if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) { + self.profile_selection_changed = true; + } let measure = matches!(self.kind, Kind::Measure { .. }); let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel }; let chain = matches!(self.kind, Kind::Fillet { chain: true, .. }); @@ -673,6 +700,9 @@ impl Dialog { /// Box selection result: replace (or add to) the active input. pub fn take_box(&mut self, s: &Session, sels: Vec, add: bool) { + if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) { + self.profile_selection_changed = true; + } let measure = matches!(self.kind, Kind::Measure { .. }); let Some(inp) = self.inputs.get_mut(self.active) else { return }; if !add { @@ -1024,9 +1054,10 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { ui.horizontal(|ui| { let active = i == d.active; // A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked. - let filled = !inp.items.is_empty(); + let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. }); + let filled = !inp.items.is_empty() || custom_axis; let text = if filled { - RichText::new(format!("↖ {} selected", inp.items.len())).color(Color32::WHITE) + RichText::new(if custom_axis { "↖ Custom Axis".into() } else { format!("↖ {} selected", inp.items.len()) }).color(Color32::WHITE) } else { RichText::new("↖ Select").color(t.text) }; @@ -1039,7 +1070,20 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { .sense(egui::Sense::CLICK); let chip = ui.add(b); publish_row(&chip); - if chip.on_hover_text(format!("{} (click to pick into this input)", hint(inp))).clicked() { + let tooltip = if custom_axis { + if let Kind::Revolve { axis: Some(axis), .. } = &d.kind { + format!( + "Custom axis\nOrigin: {}\nDirection: {}\nClick to select a replacement axis", + axis.get("origin").map(ToString::to_string).unwrap_or_default(), + axis.get("dir").map(ToString::to_string).unwrap_or_default() + ) + } else { + hint(inp).to_string() + } + } else { + format!("{} (click to pick into this input)", hint(inp)) + }; + if chip.on_hover_text(tooltip).clicked() { activate = Some(i); } if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() { @@ -1055,6 +1099,9 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { && let Some(inp) = d.inputs.get_mut(i) { inp.items.clear(); + if i == 0 { + d.profile_selection_changed = true; + } d.active = i; } } @@ -1226,6 +1273,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 +1295,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,22 +1318,58 @@ 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 { - Kind::Sketch => {} - Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, start, all } => { + Kind::Sketch => { + if d.error.is_some() + && let Some(selection @ Sel::Face { .. }) = d.inputs.first().and_then(|i| i.items.first()) + { + row_label(ui, "Attachment"); + ui.label("This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane."); + ui.end_row(); + row_label(ui, ""); + if ui.button("Use fixed plane").clicked() + && let Some(mut plane) = crate::sketch_attachment_ui::face_plane(app, selection) + { + plane["fixed"] = json!(true); + match app.run("sketch.create", json!({"plane":plane})) { + Ok(_) => { + app.pre_sketch_cam = Some(app.cam); + look_at_sketch(app); + cancel = true; + } + Err(error) => d.error = Some(error), + } + } + ui.end_row(); + } + } + Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => { row_label(ui, "Start"); - let mut st = usize::from(!start.is_empty()); - combo(ui, "ex_start", &["Profile Plane", "Offset"], &mut st); - if st == 1 && start.is_empty() { - *start = "5 mm".into(); - } else if st == 0 { - start.clear(); + let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) }; + let mut st = old; + combo(ui, "ex_start", &["Profile Plane", "Offset", "From Object"], &mut st); + if st != old { + set_object(&mut d.inputs, &mut d.active, FROM_OBJECT, st == 2); + if st == 1 && start.is_empty() { + *start = "5 mm".into(); + } + if st == 0 { + start.clear(); + } } ui.end_row(); - if !start.is_empty() { + if st != 0 { row_label(ui, "Offset"); enter |= field(ui, start); ui.end_row(); @@ -1293,8 +1379,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let order = [0usize, TWO_SIDES, 2]; let mut pos = order.iter().position(|o| o == direction).unwrap_or(0); combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos); - *direction = order.get(pos).copied().unwrap_or(0); + if pos != order.iter().position(|o| o == direction).unwrap_or(0) { + *direction = order.get(pos).copied().unwrap_or(0); + } ui.end_row(); + if *direction < 2 { + row_label(ui, "Reverse"); + let mut reverse = *direction == 1; + if ui.checkbox(&mut reverse, "Reverse Direction").changed() { + *direction = usize::from(reverse); + } + ui.end_row(); + } row_label(ui, "Extent Type"); let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(*all) * 2 }; let mut ext = cur; @@ -1312,9 +1408,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); } } + if ext == 1 { + row_label(ui, "To Offset"); + enter |= field(ui, to_offset); + ui.end_row(); + } if *direction < 2 { row_label(ui, "Taper Angle"); + let before = taper.clone(); enter |= field(ui, taper); + if *taper != before { + *taper_present = !taper.trim().is_empty(); + } ui.end_row(); } row_label(ui, "Operation"); @@ -1326,7 +1431,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } ui.end_row(); } - Kind::Revolve { angle, operation } => { + Kind::Revolve { angle, operation, .. } => { row_label(ui, "Type"); let cur = usize::from(to_object_on(&d.inputs)); let mut ty = cur; @@ -1530,6 +1635,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i); *deboss = i == 1; ui.end_row(); + ui.label(""); + ui.add( + egui::Label::new( + "Orthogonal line profiles tangent to one supported cylinder wrap automatically. Pick a wall to choose a target.", + ) + .wrap(), + ); + ui.end_row(); } Kind::Align { flip } => { // Where on the picked face or edge to snap: as picked (a face turns to meet @@ -1841,10 +1954,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } // A plane picked for a new sketch starts it right away. if matches!(d.kind, Kind::Sketch) + && d.error.is_none() && let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned() { - start_sketch(app, &sel); - cancel = true; + match start_sketch(app, &sel) { + Ok(()) => cancel = true, + Err(error) => d.error = Some(error), + } } if ok { match run_dialog(app, &d) { @@ -1862,7 +1978,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.dialog = Some(d); } else if let Some((_, marker)) = d.editing { // Editing done: put the timeline marker back where it was. - let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + let _ = roll_marker(app, marker); } } @@ -1875,12 +1991,30 @@ pub fn cancel(app: &mut SolveApp) { if let Some(d) = app.dialog.take() && let Some((_, marker)) = d.editing { - let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + let _ = roll_marker(app, marker); } } +/// Temporary rollback used by an editor is view state. Preserve both history stacks, +/// including redo and a history already at the undo limit. +pub(crate) fn roll_marker(app: &mut SolveApp, marker: Option) -> Result<(), String> { + let undo = std::mem::take(&mut app.session.undo); + let redo = std::mem::take(&mut app.session.redo); + let selection = std::mem::take(&mut app.session.selection); + let result = app.run("timeline.roll_to", marker.map(|m| json!({"position":m})).unwrap_or_else(|| json!({}))); + app.session.undo = undo; + app.session.redo = redo; + app.session.selection = selection; + result.map(|_| ()) +} + /// The pick input an extent of To Object adds (a face, vertex or point to go to). const TO_OBJECT: &str = "To Object"; +const FROM_OBJECT: &str = "From Object"; + +fn object_on(inputs: &[SelInput], label: &str) -> bool { + inputs.iter().any(|i| i.label == label) +} fn to_object_on(inputs: &[SelInput]) -> bool { inputs.iter().any(|i| i.label == TO_OBJECT) @@ -1888,18 +2022,26 @@ fn to_object_on(inputs: &[SelInput]) -> bool { /// Add or remove the To Object input; the active input moves to it when it is added. fn set_to_object(inputs: &mut Vec, active: &mut usize, on: bool) { - if on && !to_object_on(inputs) { - inputs.push(SelInput::new(TO_OBJECT, FACES | selection::VERTICES, false)); + set_object(inputs, active, TO_OBJECT, on); +} + +fn set_object(inputs: &mut Vec, active: &mut usize, label: &'static str, on: bool) { + if on && !object_on(inputs, label) { + inputs.push(SelInput::new(label, FACES | selection::VERTICES, false)); *active = inputs.len() - 1; - } else if !on && to_object_on(inputs) { - inputs.retain(|i| i.label != TO_OBJECT); + } else if !on && object_on(inputs, label) { + inputs.retain(|i| i.label != label); *active = (*active).min(inputs.len().saturating_sub(1)); } } /// The picked To Object place. fn to_object_point(inputs: &[SelInput]) -> Option { - inputs.iter().find(|i| i.label == TO_OBJECT)?.items.first().and_then(|x| match x { + object_point(inputs, TO_OBJECT) +} + +pub(crate) fn object_point(inputs: &[SelInput], label: &str) -> Option { + inputs.iter().find(|i| i.label == label)?.items.first().and_then(|x| match x { Sel::Face { point, .. } | Sel::Vertex { point, .. } | Sel::Edge { point, .. } => Some(pt(*point)), _ => None, }) @@ -1907,9 +2049,13 @@ fn to_object_point(inputs: &[SelInput]) -> Option { /// A To Object input filled from a stored place (editing). fn with_to_object(d: &mut Dialog, s: &Session, to: Option) { + with_object(d, s, TO_OBJECT, to); +} + +fn with_object(d: &mut Dialog, s: &Session, label: &'static str, to: Option) { if let Some(p) = to { - set_to_object(&mut d.inputs, &mut d.active, true); - if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) { + set_object(&mut d.inputs, &mut d.active, label, true); + if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == label) { inp.items = vec![face_sel(s, p).unwrap_or(Sel::Vertex { body: String::new(), point: p })]; } } @@ -2034,25 +2180,25 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne } /// Start a sketch on a picked plane or planar face and turn the view to face it. -pub fn start_sketch(app: &mut SolveApp, sel: &Sel) { +pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> { let plane = match sel { Sel::Plane { name } => json!(name), - Sel::Face { point, .. } => json!({"face": pt(*point)}), - _ => return, + Sel::Face { .. } => crate::sketch_attachment_ui::face_plane(app, sel).ok_or("Selected face placement no longer exists")?, + _ => return Err("Select an origin plane, construction plane or planar body face".into()), }; let before = app.cam; - if app.run("sketch.create", json!({ "plane": plane })).is_ok() { - app.pre_sketch_cam = Some(before); - look_at_sketch(app); - // On a face: look at the picked point, not the plane's origin (a corner of the face), - // which left the face filling the view off-centre like a big square. - if let Sel::Face { point, .. } = sel - && let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to) - { - to.target = *point; - app.animate_to(to); - } + app.run("sketch.create", json!({ "plane": plane }))?; + app.pre_sketch_cam = Some(before); + look_at_sketch(app); + // On a face: look at the picked point, not the plane's origin (a corner of the face), + // which left the face filling the view off-centre like a big square. + if let Sel::Face { point, .. } = sel + && let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to) + { + to.target = *point; + app.animate_to(to); } + Ok(()) } /// Animate the camera to look straight at the active sketch plane. @@ -2075,17 +2221,58 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] { } fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { - for (cmd, params) in apply_commands(app, d)? { - app.run(&cmd, params)?; + let commands = apply_commands(app, d)?; + if let Some((_, marker)) = d.editing { + // Record the actual pre-edit timeline state in the command's undo snapshot. + roll_marker(app, marker)?; + } + for (cmd, params) in commands { + if let Err(error) = app.run(&cmd, params) { + if let Some((id, _)) = d.editing { + let position = app.session.doc.feature_index(id); + let _ = roll_marker(app, position); + } + return Err(error); + } } Ok(()) } +/// Placement used to edit a definition in the selected authoring occurrence. +pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::doc::Mat { + if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) } +} + /// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same. pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let cmds = dialog_commands(app, d)?; if let Some((id, _)) = d.editing { - let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?; + let (cmd, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?; + if let Some(feature) = app.session.doc.feature(id) { + let original_all = match &feature.kind { + FeatureKind::Extrude { sketch, profiles: ProfileSel::All, .. } + | FeatureKind::Revolve { sketch, profiles: ProfileSel::All, .. } + | FeatureKind::Emboss { sketch, profiles: ProfileSel::All, .. } => Some(*sketch), + _ => None, + }; + if let Some(sketch) = original_all + && !d.profile_selection_changed + && let Some(ss) = app.session.world_state().sketch(sketch) + { + let mut selected: Vec = sels(d, 0) + .iter() + .filter_map(|item| match item { + Sel::Profile { sketch: id, index } if *id == sketch => Some(*index), + _ => None, + }) + .collect(); + selected.sort_unstable(); + selected.dedup(); + if selected == (0..ss.profiles.len()).collect::>() && sels(d, 0).len() == selected.len() { + params["profiles"] = json!("all"); + } + } + } return Ok(vec![("timeline.redefine".into(), json!({"feature": id, "command": cmd, "params": params}))]); } Ok(cmds) @@ -2100,6 +2287,31 @@ pub fn preview_commands(app: &SolveApp, d: &Dialog) -> Result Result, String> { + let Some(dialog) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Emboss { .. })) else { return Ok(None) }; + let Some((feature, _)) = dialog.editing else { return Ok(None) }; + let Some(feature) = app.session.doc.feature(feature) else { return Err("the edited feature is missing".into()) }; + let FeatureKind::Emboss { sketch: source, face: Some(face), .. } = &feature.kind else { return Ok(None) }; + if *source != sketch { + return Ok(None); + } + let model = app.session.model.state(); + let sketch = model.sketch(sketch).ok_or("the Emboss sketch is missing")?; + let body = model.body(&face.body).ok_or("the Emboss target body is missing")?; + let owner = app.session.doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0); + let plane = sketch.plane; + let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body).map_err(|error| error.to_string())?; + solvecraft_engine::geom::Plane::new( + solvecraft_engine::doc::apply_point(&frame, plane.origin), + solvecraft_engine::doc::apply_vector(&frame, plane.x), + solvecraft_engine::doc::apply_vector(&frame, plane.y), + ) + .map(Some) + .ok_or_else(|| "the Emboss author frame is degenerate".into()) +} + /// The commands (id, parameters) a dialog's OK runs. fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let s = &app.session; @@ -2115,13 +2327,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect() }; let (cmd, params): (&str, Value) = match &d.kind { - Kind::Extrude { distance, direction, operation, distance2, taper, start, all, .. } => { + Kind::Extrude { distance, direction, operation, distance2, taper, taper_present, to_offset, start, all, .. } => { need(0, "profiles or a planar face")?; let mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}); let tapered = s.doc.eval(taper, ValueKind::Angle).map(|v| v.abs() > 1e-12).unwrap_or(!taper.trim().is_empty()); if *direction == TWO_SIDES { common["distance2"] = json!(distance2); - } else if *direction < 2 && tapered { + } else if *direction < 2 && (*taper_present || tapered) { common["taper"] = json!(taper); } if !start.trim().is_empty() { @@ -2132,6 +2344,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } if to_object_on(&d.inputs) { common["to"] = to_object_point(&d.inputs).ok_or("select the object to extrude to")?; + if !to_offset.trim().is_empty() { + common["to_offset"] = json!(to_offset); + } + } + if object_on(&d.inputs, FROM_OBJECT) { + common["from"] = object_point(&d.inputs, FROM_OBJECT).ok_or("select the object to extrude from")?; } let with = |extra: Value| -> Value { let mut p = common.clone(); @@ -2154,21 +2372,18 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } return Ok(out); } - Kind::Revolve { angle, operation } => { + Kind::Revolve { angle, operation, axis: stored_axis } => { need(0, "profiles")?; - need(1, "an axis")?; let axis = match sels(d, 1).first() { Some(Sel::Axis { name }) => json!(name), Some(Sel::SketchCurve { id }) => json!(id), - _ => return Err("select an axis".into()), + _ => stored_axis.clone().ok_or("select an axis")?, }; let (sketch, idx) = profiles(); let mut p = json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")}); if to_object_on(&d.inputs) { p["to"] = to_object_point(&d.inputs).ok_or("select the object to turn to")?; - // A To Object turn has no second side. - return Ok(vec![("solid.revolve".into(), p)]); } ("solid.revolve", p) } @@ -2350,7 +2565,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Kind::Emboss { depth, deboss } => { need(0, "profiles")?; let (sketch, idx) = profiles(); - ("solid.emboss", json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }})) + let mut p = json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }}); + if let Some(Sel::Face { body, point, .. }) = sels(d, 1).first() { + p["face"] = pt(*point); + p["targets"] = json!([body]); + } + ("solid.emboss", p) } Kind::ReplaceFace => { need(0, "faces to move")?; @@ -2772,6 +2992,18 @@ pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option { best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1) } +fn emboss_face_sel(s: &Session, component: u64, face: &solvecraft_engine::doc::EmbossFace, persistent: &str) -> Option { + let source = s.model.state(); + let body = source.body(&face.body)?; + let index = solvecraft_engine::doc::naming::face_names(body).iter().position(|name| name == persistent)?; + let owner = s.doc.model_body_component(body); + let world_name = s.doc.world_body_name(&source, &face.body, owner, &face.path); + let author = solvecraft_engine::doc::EmbossFace::component_frame(&s.doc, component, &face.author_path).ok()?; + let point = solvecraft_engine::doc::apply_point(&author, face.at); + s.world_state().body(&world_name)?; + Some(Sel::Face { body: world_name, index, point }) +} + pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { match pl { PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }), @@ -2829,16 +3061,21 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option) -> Option { + FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, convention, to } => { let mut d = start("solid.revolve")?; - d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) }; + let frame = feature_frame(s, f.component); + let stored_axis = match axis { + AxisRef::Line { origin, dir } => Some(json!({ + "origin": pt(solvecraft_engine::doc::apply_point(&frame, *origin)), + "dir": pt(solvecraft_engine::doc::apply_vector(&frame, *dir)) + })), + _ => None, + }; + d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis }; + d.extra.insert("convention".into(), json!(convention)); if let Some(a2) = angle2 { d.extra.insert("angle2".into(), json!(a2)); } if *symmetric { d.extra.insert("direction".into(), json!("symmetric")); } - with_to_object(&mut d, s, *to); + if let Some(point) = to { + set_to_object(&mut d.inputs, &mut d.active, true); + if let Some(input) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) { + input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, *point) }]; + } + } let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); if let Some(inp) = d.inputs.get_mut(0) { inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); } let ax = match axis { AxisRef::World { axis } => Some(Sel::Axis { name: axis.to_ascii_uppercase() }), + AxisRef::SketchAxis { axis } => Some(Sel::Axis { name: axis.to_ascii_lowercase() }), AxisRef::SketchLine { curve } => Some(Sel::SketchCurve { id: curve.clone() }), _ => None, }; @@ -3278,7 +3530,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => { let mut d = start("solid.emboss")?; d.kind = Kind::Emboss { depth: depth.clone(), deboss: *deboss }; let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); @@ -3288,6 +3540,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index 6890658e..470ecf9d 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,64 @@ 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 body_look = |body: &solvecraft_engine::doc::ModelBody| { + app.session.doc.model_body_look(body).or_else(|| { + (body.body.paint().is_some() || body.imported.is_some() || body.body.color().is_some()).then(|| { + let color = + app.session.doc.body_color(body).map(|rgb| rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).unwrap_or([196, 199, 204]); + solvecraft_engine::doc::appearance::Look::custom(color, app.session.doc.model_body_opacity(body)) + }) + }) + }; + let existing = app.session.selection.iter().find_map(|sel| match sel { + Sel::Body { name } => state.body(name).and_then(body_look), + 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(|| body_look(b))), + _ => 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 +176,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 +185,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 +228,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 +273,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 +311,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 +337,72 @@ 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)); + // Decimal formatting must not round a saved look merely by drawing its editor. + if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.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 +419,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 +439,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 +449,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 +526,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 +549,90 @@ 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 real_frames_and_unchanged_apply_keep_precise_opacity() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#abcdef","opacity":0.123456})).unwrap(); + for cancel in [true, false] { + h.app.session.selection = vec![Sel::Body { name: "Body1".into() }]; + h.app.start("appearance.assign"); + h.frames(3); + let Kind::Appearance(appearance) = &h.app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") }; + assert_eq!(appearance.opacity, 0.123456); + h.step(&json!({"key":if cancel {"Escape"} else {"Enter"}})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + assert_eq!(h.app.session.doc.appearances.bodies["Body1"].opacity, 0.123456); + } + } + + #[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_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 07d08727..354b8bab 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -273,7 +273,7 @@ fn pt(v: Vec3) -> Value { fn occurrence_of_body(s: &Session, body: &str) -> Option { let st = s.world_state(); let b = st.body(body)?; - let comp = s.doc.body_component(&b.name, b.feature); + let comp = s.doc.model_body_component(b); if comp == 0 { Some(0) } else { s.doc.occurrence_of(comp).map(|o| o.id) } } @@ -819,13 +819,11 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O if cmds.is_empty() || !(moves || solvecraft_engine::frames::placed(s)) { return None; } - let mut scratch = s.scratch(); - for (id, p) in cmds { - if let Err(e) = scratch.execute(id, p) { - return Some(Err(e.to_string())); - } - } - let (before, after) = (s.world_state(), scratch.world_state()); + let preview = match s.preview_visible_world(cmds) { + Ok(preview) => preview, + Err(error) => return Some(Err(error.to_string())), + }; + let (before, after) = (preview.before, preview.after); // A moved component looks like itself (no new surface), with its old place as the ghost. let colors = Colors { added: colors.body, ..colors }; let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::preview::build(&before, &after, colors))); diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 5331e429..f4b6db8b 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -16,6 +16,9 @@ use crate::dialogs::{Dialog, Kind, combo, field, row_label}; use crate::selection::{BODIES, SelInput}; use crate::theme::Tokens; +#[path = "configuration_rules_ui.rs"] +mod configuration_rules_ui; + /// One keyed value of a motion study: a joint's value (index) at steps. #[derive(Clone, Debug, PartialEq)] pub struct Track { @@ -60,6 +63,7 @@ pub enum Mo { Configurations { new_row: String, error: Option, + rule_editor: configuration_rules_ui::Editor, }, } @@ -114,7 +118,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { vec![], ), "explode.create" => (Mo::Explode { name: n("Exploded View", "explode.list"), scale: 1.0, shown: None, saved: None, error: None }, vec![]), - "config.table" | "config.configure" => (Mo::Configurations { new_row: String::new(), error: None }, vec![]), + "config.table" | "config.configure" => (Mo::Configurations { new_row: String::new(), error: None, rule_editor: Default::default() }, vec![]), _ => return None, }; Some((Kind::Motion(mo), inputs)) @@ -263,7 +267,11 @@ pub fn column_parts(c: &str) -> (&str, &str) { /// The value a table cell edit sends: suppress columns take booleans, parameters expressions. pub fn cell_value(column: &str, text: &str) -> Value { match column_parts(column).0 { - "suppress" => json!(matches!(text.trim().to_ascii_lowercase().as_str(), "suppressed" | "true" | "yes" | "1" | "off")), + "suppress" => match text.trim().to_ascii_lowercase().as_str() { + "suppressed" | "true" | "yes" | "1" | "off" => json!(true), + "unsuppressed" | "false" | "no" | "0" | "on" => json!(false), + _ => json!(text), + }, _ => json!(text), } } @@ -578,8 +586,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel ui.end_row(); } } - Mo::Configurations { new_row, error } => { - let v = app.session.execute("config.table", &json!({})).unwrap_or(Value::Null); + Mo::Configurations { new_row, error, rule_editor } => { + let revision = app.session.revision; + let v = rule_editor.table.as_ref().filter(|(r, _)| *r == revision).map(|(_, v)| v.clone()).unwrap_or_else(|| { + let value = app.session.execute("config.table", &json!({})).unwrap_or(Value::Null); + rule_editor.table = Some((revision, value.clone())); + value + }); let mut tb = table(&v); // Bare numbers show with their parameter's unit ("40" is 40 mm), as Fusion shows them. let kinds = app.session.doc.all_param_exprs(); @@ -588,7 +601,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel let (kind, n) = column_parts(c); if kind == "param" && val.trim().parse::().is_ok() - && let Some(unit) = kinds.iter().find(|x| x.0 == n).and_then(|x| unit_of(x.2)) + && let Some(unit) = app.session.doc.param(n).map(|parameter| parameter.unit.clone()).or_else(|| { + kinds.iter().find(|x| x.0 == n).and_then(|x| { + unit_of(x.2).map(|unit| { + if x.2 == solvecraft_engine::doc::expr::Kind::Length { app.session.doc.units.clone() } else { unit.to_string() } + }) + }) + }) { *val = format!("{} {unit}", val.trim()); } @@ -597,56 +616,137 @@ 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(), + }; + let label = v["targets"] + .as_array() + .and_then(|targets| targets.iter().find(|target| target["key"].as_str() == Some(c))) + .and_then(|target| target["label"].as_str()) + .filter(|_| matches!(kind, "component_appearance" | "face_appearance")) + .map(str::to_string) + .unwrap_or(label); + 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: if kind == "face_appearance" {c.as_str()} else {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 appearance = matches!(kind, "appearance" | "component_appearance" | "face_appearance"); + let shown = if 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 = kind == "material" || appearance; + if style { + let mut choice = None; + ui.menu_button("Library", |ui| { + if ui + .button(if kind == "appearance" { + "Follow material" + } else if appearance { + "Follow inherited appearance" + } 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 = (name.clone(), c.clone()); + let mut text = rule_editor.drafts.get(&id).cloned().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() { + rule_editor.drafts.insert(id.clone(), text.clone()); + } + if r.lost_focus() { + rule_editor.drafts.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 +772,24 @@ 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}))); + } + } + } + for component in &app.session.doc.components { + if component.id != 0 && !tb.columns.contains(&format!("component_appearance:{}", component.id)) { + options.push((format!("{} (component appearance)", component.name), json!({"component_appearance":component.id}))); + } + } + for selected in &app.session.selection { + if let solvecraft_engine::Sel::Face { body, point, .. } = selected { + options + .push((format!("Selected face on {body} (shared appearance)"), json!({"face_appearance":{"body":body,"point":point}}))); + } + } let mut pick: Option = None; ui.menu_button("Add Column", |ui| { egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { @@ -681,6 +799,50 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel ui.close(); } } + ui.menu_button("Face Appearance", |ui| { + ui.label("Semantic faces share their appearance across instances."); + let state = app.session.model.state(); + for body in &state.bodies { + if body.body.is_mesh() { + continue; + } + ui.menu_button(format!("Faces on {}", body.name), |ui| { + if let Ok(faces) = body.body.faces((body.body.size() * 2e-3).max(1e-3)) { + let mut seen = std::collections::BTreeSet::new(); + let mesh = body.mesh(); + let mut points = std::collections::BTreeMap::new(); + for (triangle, index) in mesh.triangles.iter().zip(&mesh.tri_face) { + if let std::collections::btree_map::Entry::Vacant(entry) = points.entry(*index as usize) + && let Some([a, b, c]) = mesh.tri(triangle) + { + entry.insert((a + b + c) * (1. / 3.)); + } + } + for face in faces { + // A curved face centroid can lie on another surface. + // Pick an interior triangle point on this actual face. + let Some(point) = points.get(&face.index).copied() else { continue }; + let Ok(target) = app.session.doc.configuration_face_target(body, face.index, point) else { continue }; + let supported = if target.certificate.is_some() { + // Capture read this exact face's current source certificate. + // Enumerate its pieces once in the command, not per menu face. + app.session.doc.certify_config_face(&target).is_ok() + } else { + app.session.doc.configuration_face_indices(body, &target).is_ok() + }; + if !supported || !seen.insert(target.name.clone()) { + continue; + } + let Some((_, role)) = target.name.split_once(':') else { continue }; + if ui.button(role).clicked() { + pick = Some(json!({"face_appearance":{"target":target}})); + ui.close(); + } + } + } + }); + } + }); }); }); if let Some(p) = pick { @@ -688,9 +850,18 @@ 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 if let Some(n) = p.get("component_appearance") { + json!({ "component_appearances": [n] }) + } else if let Some(n) = p.get("face_appearance") { + json!({ "face_appearances": [n] }) + } else { + json!({ "appearances": [p.get("appearance")] }) }, ) } else { @@ -700,6 +871,21 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel } }); ui.end_row(); + configuration_rules_ui::show(ui, app, error, rule_editor); + if let Some(styles) = v["style_status"].as_array() { + for style in styles.iter().filter(|style| style["dormant"].as_bool() == Some(true)) { + ui.add( + egui::Label::new(format!( + "{}: appearance dormant while its feature is suppressed", + style["target"].as_str().unwrap_or("Face") + )) + .wrap(), + ); + } + } + if let Some(message) = v["style_error"].as_str() { + ui.add(egui::Label::new(RichText::new(message).color(t.error)).wrap()); + } if let Some(e) = error { ui.label(""); ui.label(RichText::new(e.as_str()).color(t.error)); @@ -747,7 +933,7 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result { + 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..dafea6e2 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}") @@ -708,9 +714,17 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { if !(lo.0.is_finite() && hi.0 > lo.0 && hi.1 > lo.1) { return true; } - // Fit with a margin, leaving the dialog's side free. - let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), rect.max - vec2(320.0, 60.0)); - let area = if area.width() < 100.0 { rect.shrink(40.0) } else { area }; + // Fit all retained islands to the exposed viewport. The command panel can grow + // to its width limit, so its actual edge, rather than a fixed reserve, bounds the view. + let panel_left = painter + .ctx() + .memory(|m| m.area_rect(egui::Id::new(("sc_dialog", "FLAT PATTERN")))) + .map(|panel| panel.left()) + .unwrap_or(rect.right() - crate::frame::DIALOG_MAX_W); + let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), pos2(panel_left.min(rect.right()) - 20.0, rect.bottom() - 60.0)); + if area.width() <= 0.0 || area.height() <= 0.0 { + return true; + } let scale = (f64::from(area.width()) / (hi.0 - lo.0)).min(f64::from(area.height()) / (hi.1 - lo.1)); let ox = f64::from(area.center().x) - (lo.0 + hi.0) * 0.5 * scale; let oy = f64::from(area.center().y) + (lo.1 + hi.1) * 0.5 * scale; @@ -727,6 +741,26 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { painter.add(egui::Shape::closed_line(pts, Stroke::new(1.4, t.text))); } } + // A pocket retains material below (or above) the cut. Dashed contours and a depth + // annotation distinguish it from the solid through-cut boundaries. + let pockets: Vec<&Value> = v["pockets"].as_array().into_iter().flatten().collect(); + for pocket in &pockets { + let outer: Vec<(f64, f64)> = pocket["outline"].as_array().into_iter().flatten().filter_map(read).collect(); + for contour in std::iter::once(&pocket["outline"]).chain(pocket["cutouts"].as_array().into_iter().flatten()) { + let mut pts: Vec = contour.as_array().into_iter().flatten().filter_map(read).map(to).collect(); + if pts.len() >= 3 { + pts.push(pts[0]); + painter.extend(egui::Shape::dashed_line(&pts, Stroke::new(1.6, t.accent), 3.0, 3.0)); + } + } + if !outer.is_empty() { + let bounds = outer.iter().map(|p| to(*p)).fold(Rect::NOTHING, |r, p| r.union(Rect::from_min_max(p, p))); + let anchor = bounds.center_top() - vec2(0.0, 6.0); + if let Some(label) = pocket_label(pocket) { + painter.text(anchor, Align2::CENTER_BOTTOM, label, FontId::proportional(12.0), t.accent); + } + } + } for b in v["bends"].as_array().into_iter().flatten() { let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue }; let col = if b["direction"] == "up" { up_col } else { down_col }; @@ -740,9 +774,33 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { painter.line_segment([pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)], Stroke::new(2.0, col)); painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, label, FontId::proportional(12.0), t.text_dim); } + if !pockets.is_empty() { + y += 20.0; + painter.extend(egui::Shape::dashed_line( + &[pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)], + Stroke::new(1.6, t.accent), + 3.0, + 3.0, + )); + painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, "Pocket boundary (material remains)", FontId::proportional(12.0), t.text_dim); + } true } +fn pocket_label(pocket: &Value) -> Option { + let depth = pocket["depth_mm"].as_f64()?; + if !depth.is_finite() || depth <= 0.0 { + return None; + } + let side = match pocket["side"].as_str()? { + "top" => "Top", + "bottom" => "Bottom", + "internal" => "Internal", + _ => return None, + }; + Some(format!("{side} pocket: {depth} mm deep")) +} + #[cfg(test)] #[path = "dialogs_sheet_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index e4653ee6..24203a97 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -106,3 +106,170 @@ 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] +fn shallow_pocket_overlay_labels_depth_without_drawing_a_through_void() { + let mut app = plate(); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + app.run("sketch.create", json!({"plane": "XY", "name": "Pocket"})).unwrap(); + app.run("sketch.rectangle.two_point", json!({"p0": [20, 20], "p1": [30, 30]})).unwrap(); + app.run("sketch.finish", json!({})).unwrap(); + app.run("solid.extrude", json!({"sketch": "Pocket", "distance": 1, "operation": "cut"})).unwrap(); + let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); + assert_eq!(flat["cutouts"], json!([])); + assert_eq!(flat["pockets"][0]["depth_mm"], 1.0); + app.start("sheet.flat_pattern"); + let mut dialog = app.dialog.clone().unwrap(); + let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; + *data = Some(Ok(flat)); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("flat-pocket-test"))); + assert!(overlay(&painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); + }); + output.textures_delta.clear(); + let texts: Vec<&str> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(text) => Some(text.galley.text()), + _ => None, + }) + .collect(); + assert!(texts.contains(&"Bottom pocket: 1 mm deep")); + assert!(texts.contains(&"Pocket boundary (material remains)")); + let paths: Vec<_> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Path(path) => Some(path), + _ => None, + }) + .collect(); + assert!(!paths.is_empty(), "the retained sheet outline is drawn"); + assert!(paths.iter().all(|p| p.fill == Color32::TRANSPARENT), "pockets must not be rendered as filled voids"); + let lines = output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::LineSegment { .. })).count(); + assert!(lines > 6, "pocket contours and legend use dashed lines, not through-cut strokes"); +} + +#[test] +fn flat_pattern_islands_fit_before_the_actual_command_panel() { + let mut app = plate(); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + app.run("sketch.create", json!({"plane": "XY", "name": "Retain"})).unwrap(); + for x in [20, 60] { + app.run("sketch.rectangle.two_point", json!({"p0": [x, 20], "p1": [x + 10, 30]})).unwrap(); + } + app.run("sketch.finish", json!({})).unwrap(); + app.run("solid.extrude", json!({"sketch": "Retain", "profiles": "all", "through_all": true, "direction": "symmetric", "operation": "intersect"})) + .unwrap(); + let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); + assert_eq!(flat["outline"].as_array().unwrap().len(), 2); + app.start("sheet.flat_pattern"); + let mut dialog = app.dialog.clone().unwrap(); + let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; + *data = Some(Ok(flat)); + let ctx = egui::Context::default(); + let id = egui::Id::new(("sc_dialog", "FLAT PATTERN")); + let mut output = egui::FullOutput::default(); + for _ in 0..3 { + output = ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), ..Default::default() }, |ui| { + crate::frame::docked("FLAT PATTERN", id, pos2(1400.0, 294.0), 540.0, crate::frame::Width::Normal).min_width(360.0).show(ui.ctx(), |ui| { + ui.label("Flat size: 50 × 10 × 2.5 mm"); + }); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Background, egui::Id::new("island-fit"))); + assert!(overlay(&painter, Rect::from_min_max(pos2(250.0, 126.0), pos2(1400.0, 832.0)), &dialog)); + }); + output.textures_delta.clear(); + } + let panel = ctx.memory(|m| m.area_rect(id)).unwrap(); + let outlines: Vec<_> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT => Some(p), + _ => None, + }) + .collect(); + assert_eq!(outlines.len(), 2, "both retained islands are drawn"); + assert!( + outlines.iter().flat_map(|p| &p.points).all(|p| p.x <= panel.left() - 19.0), + "both complete islands must remain exposed before the command panel: {panel:?}" + ); +} + +#[test] +fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() { + let mut h = crate::scenario::Harness::new(); + for (command, params) in [ + ("sketch.create", json!({"plane":"XY","name":"Base"})), + ("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]})), + ("sketch.finish", json!({})), + ("sheet.flange", json!({"sketch":"Base"})), + ("sketch.create", json!({"plane":"XY","name":"Pocket"})), + ("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})), + ] { + h.app.run(command, params).unwrap(); + } + let path = h.dir.join("picked-pocket.dxf"); + let chosen = path.to_string_lossy().into_owned(); + h.app.services.pick_save = Some(Box::new(move |name, extensions| { + assert!(name.ends_with("-Body1.dxf")); + assert_eq!(extensions, &["dxf"]); + Some(chosen.clone()) + })); + h.app.start("sheet.flat_pattern"); + h.settle(); + let undo = h.app.session.undo.len(); + h.step(&json!({"widget":"Export DXF…"})).unwrap(); + let exported = std::fs::read_to_string(&path).expect("actual Export DXF button writes the picker destination"); + assert!(exported.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM")); + assert!(exported.contains("OUTLINE")); + assert_eq!(h.app.session.undo.len(), undo, "export does not alter the part"); + assert!(h.app.dialog.is_some(), "export keeps the flat viewer open"); +} + +#[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/documents.rs b/crates/ui-egui/src/documents.rs index 60a1ca1f..1e97d42b 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -81,6 +81,8 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { app.cam_anim = None; app.preview = crate::preview::PreviewState::default(); app.viewport.hover = None; + app.menu.props = None; + crate::sketch_attachment_ui::clear(app); let out_rev = app.session.revision; let out = DocSlot { session: std::mem::replace(&mut app.session, slot.session), @@ -140,6 +142,8 @@ pub fn adopt(app: &mut SolveApp, session: Session) { let rev = app.session.revision; crate::dialogs::cancel(app); app.tool = None; + app.menu.props = None; + crate::sketch_attachment_ui::clear(app); app.session = session; app.session.invalidate_view(rev); app.cam = Camera::default(); diff --git a/crates/ui-egui/src/extrude_edit_tests.rs b/crates/ui-egui/src/extrude_edit_tests.rs new file mode 100644 index 00000000..467518e4 --- /dev/null +++ b/crates/ui-egui/src/extrude_edit_tests.rs @@ -0,0 +1,280 @@ +//! Exercise the actual feature editor, including frames rendered before OK. +use crate::scenario::Harness; +use serde_json::{Value, json}; +use solvecraft_engine::doc::{Extent, FeatureKind}; + +fn make(params: Value) -> Harness { + make_in(Harness::new(), params) +} +fn make_in(mut h: Harness, params: Value) -> Harness { + h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let mut p = json!({"sketch":"Base","distance":"10 mm","operation":"new","name":"Depth","body_name":"Main"}); + p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone()); + h.app.run("solid.extrude", p).unwrap(); + h.frames(4); + h +} +fn extent(h: &Harness) -> Extent { + let FeatureKind::Extrude { extent, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") }; + extent.clone() +} +fn open(h: &mut Harness) { + h.step(&json!({"call":"ui.editFeature","params":{"feature":"Depth"}})).unwrap(); + h.frames(8); +} + +#[test] +fn unchanged_all_profiles_includes_profiles_added_after_edit() { + let mut h = make(json!({})); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + let FeatureKind::Extrude { profiles, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") }; + assert!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All)); + h.app.run("sketch.edit", json!({"sketch":"Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let state = h.app.session.world_state(); + let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum(); + assert!((volume - 1250.0).abs() < 1e-7, "{volume}"); +} + +#[test] +fn unchanged_actual_editor_preserves_all_extents_and_parameter_identities() { + for p in [ + json!({"direction":"negative","distance":"10"}), + json!({"direction":"negative","distance":"-10 mm"}), + json!({"direction":"symmetric","distance":"5 mm"}), + json!({"distance":"10 mm","distance2":"3 mm"}), + json!({"from":[0,0,5],"start_offset":"0 mm"}), + json!({"to":[0,0,10],"to_offset":"3 mm"}), + json!({"to":[0,0,-10],"to_offset":"-2 mm"}), + json!({"taper":"0 deg"}), + ] { + let mut h = make(p.clone()); + let original = extent(&h); + let names = h.app.session.doc.find_feature("Depth").unwrap().param_names.clone(); + let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{p}: {:?}", h.app.session.log); + assert_eq!(extent(&h), original, "{p}"); + assert_eq!(h.app.session.doc.find_feature("Depth").unwrap().param_names, names, "{p}"); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before, "{p}"); + } +} + +#[test] +fn negative_edit_keeps_downstream_distance_reference_positive() { + let mut h = make(json!({"direction":"negative","distance":"10"})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":"d1","corner":[30,0,0],"body_name":"Dependent"})).unwrap(); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0); + assert_eq!(h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z, 10.0); +} + +#[test] +fn cancel_after_canvas_edit_preserves_document_and_undo() { + let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + let original = extent(&h); + let undo = h.app.session.undo.len(); + open(&mut h); + assert_eq!(crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0.z, 5.0); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"4 mm"})).unwrap(); + h.frames(8); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(extent(&h), original); + assert_eq!(h.app.session.undo.len(), undo); +} + +#[test] +fn to_offset_canvas_edit_changes_offset_and_one_undo_restores_extent() { + let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + let original = extent(&h); + open(&mut h); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"4 mm"})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h).to_offset.as_deref(), Some("4 mm")); + assert_eq!(extent(&h).distance, original.distance); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 19.0); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(extent(&h), original); +} + +#[test] +fn moved_owner_references_survive_actual_edit_with_another_component_active() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20]})).unwrap(); + let other = h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap(); + h.app.run("occurrence.move", json!({"occurrence":other["occurrence"],"translate":[200,0,40]})).unwrap(); + let original = extent(&h); + let active = h.app.session.active_component; + open(&mut h); + let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0; + assert_eq!(base.z, 25.0); + assert!(base.x >= 100.0 && base.x <= 110.0); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h), original); + assert_eq!(h.app.session.active_component, active); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 37.0); +} + +#[test] +fn dragged_to_object_handle_edits_offset_not_dormant_distance() { + let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + open(&mut h); + let original = extent(&h); + h.step(&json!({"drag":[{"handle":"arrow"},{"world":[5,5,21]}],"ctrl":true})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log); + assert_eq!(extent(&h).distance, original.distance); + let state = h.app.session.world_state(); + let z = state.body("Main").unwrap().mesh().bounds().max.z; + assert!((z - 21.0).abs() < 0.1, "dragged end {z}"); +} + +#[test] +fn referenced_offset_recomputes_after_edit_and_save_reopen() { + let mut h = Harness::new(); + h.app.run("parameters.add", json!({"name":"clearance","expression":"2 mm"})).unwrap(); + let mut h = make_in(h, json!({"to":[0,0,15],"to_offset":"clearance"})); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance")); + h.app.run("parameters.change", json!({"name":"clearance","expression":"6 mm"})).unwrap(); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0); + let path = h.dir.join("extent.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance")); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0); +} + +#[test] +fn from_object_start_can_be_explicitly_changed_to_profile_plane() { + let mut h = make(json!({"from":[0,0,5]})); + open(&mut h); + h.step(&json!({"click":{"handle":"dialog:Start"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Start:Profile Plane"}})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(extent(&h).from.is_none()); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, 0.0); +} + +#[test] +fn explicit_reverse_keeps_original_expression_and_parameter_sign() { + let mut h = make(json!({"distance":"10"})); + open(&mut h); + h.step(&json!({"widget":"Reverse Direction"})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h).distance, "10"); + assert_eq!(extent(&h).direction, solvecraft_engine::doc::Direction::Negative); + assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, -10.0); +} + +#[test] +fn failed_edit_and_cancel_keep_full_history_and_existing_redo() { + let mut h = make(json!({})); + for i in 0..205 { + h.app.run("timeline.rename", json!({"feature":1,"name":format!("Base{i}")})).unwrap(); + } + h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[30,0,0],"body_name":"RedoBody"})).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + let undo = h.app.session.undo.len(); + let redo = h.app.session.redo.len(); + assert_eq!(undo, 199); + assert_eq!(redo, 1); + h.app.session.selection = vec![solvecraft_engine::Sel::Body { name: "Main".into() }]; + let selection = h.app.session.selection.clone(); + let original = serde_json::to_value(&*h.app.session.doc).unwrap(); + open(&mut h); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.redo.len(), redo); + assert_eq!(h.app.session.selection, selection); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"missing_extent"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_some(), "failed edit must remain editable"); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.redo.len(), redo); + assert_eq!(h.app.session.selection, selection); + h.app.run("edit.redo", json!({})).unwrap(); + assert!(h.app.session.world_state().body("RedoBody").is_some()); +} + +#[test] +fn tapered_from_and_signed_to_have_correct_start_volume_and_preview_parity() { + for (params, lo, hi) in [ + (json!({"from":[0,0,5],"distance":"10 mm","taper":"atan(0.1)"}), 5.0, 15.0), + (json!({"from":[0,0,5],"to":[0,0,15],"direction":"negative","taper":"atan(0.1)"}), 5.0, 15.0), + (json!({"from":[0,0,5],"to":[0,0,-5],"direction":"negative","taper":"atan(0.1)"}), -5.0, 5.0), + (json!({"from":[0,0,5],"to":[0,0,-5],"direction":"positive","taper":"atan(0.1)"}), -5.0, 5.0), + ] { + let mut h = make(params.clone()); + let state = h.app.session.world_state(); + let body = state.body("Main").unwrap(); + let bounds = body.mesh().bounds(); + assert!((bounds.min.z - lo).abs() < 1e-8 && (bounds.max.z - hi).abs() < 1e-8, "{params}: {bounds:?}"); + let expected = 10.0 * (100.0 + 120.0 + 144.0) / 3.0; + let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume; + assert!((volume - expected).abs() < 1e-5, "{params}: volume{volume} expected{expected}"); + let original = extent(&h); + open(&mut h); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview(&commands).unwrap(); + assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h), original); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds); + } +} + +#[test] +fn adjacent_profiles_from_slanted_face_use_same_merged_centre_as_geometry() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":30,"width":30,"height":2,"corner":[-10,-10,10],"body_name":"Reference"})).unwrap(); + h.app.run("solid.move", json!({"bodies":["Reference"],"axis":[0,1,0],"angle":"atan(0.1)","origin":[0,0,10]})).unwrap(); + h.app.run("sketch.create", json!({"plane":"XY","name":"Adjacent"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,10]})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[10,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let angle = 0.1f64.atan(); + let point = [2.0 * angle.sin(), 0.0, 10.0 + 2.0 * angle.cos()]; + h.app.run("solid.extrude", json!({"sketch":"Adjacent","distance":10,"from":point,"name":"Depth","body_name":"Main","operation":"new"})).unwrap(); + let min_z = h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z; + open(&mut h); + let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0; + assert!((base.z - min_z).abs() < 1e-7, "displayed start{} geometry start{min_z}", base.z); + // Averaging the two unmerged rectangle loops gives x=3.5; their internal + // seam must not bias the reference ray used by the extent handle. + let unmerged_height = 10.0 + 2.0 / angle.cos() - 3.5 * angle.tan(); + assert!((base.z - unmerged_height).abs() > 0.01, "fixture must distinguish the raw-profile centre"); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!((h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z - min_z).abs() < 1e-7); +} + +#[test] +fn rotated_second_occurrence_keeps_saved_from_and_to_points_exact() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); + let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone(); + h.app.run("occurrence.move", json!({"occurrence":second,"axis":[1,0,0],"angle":"17 deg"})).unwrap(); + h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap(); + let original = extent(&h); + let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(extent(&h), original); + let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); + assert_eq!(after, before); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34b7b49f..11577212 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,8 @@ mod status_tests { assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last()); } } +#[cfg(test)] +mod extrude_edit_tests; mod file_load; pub mod palette; pub mod params_dialog; @@ -63,10 +65,19 @@ mod preselect_tests; pub mod preview; pub mod recovery_ui; pub mod ref_images; +#[cfg(test)] +mod revolve_edit_tests; +#[cfg(test)] +mod revolve_manipulator_tests; pub mod scenario; pub mod selection; pub mod shortcut_box; pub mod sketch3d; +#[cfg(test)] +mod sketch3d_frame_ui_tests; +pub(crate) mod sketch_attachment_ui; +#[cfg(test)] +mod sketch_attachment_ui_tests; pub mod sketch_dims; #[cfg(test)] mod sketch_edit_tests; @@ -404,7 +415,14 @@ impl SolveApp { match &r { Ok(_) => { self.session.echo(format!("{} done", command_label(id))); - if matches!(id, "file.new" | "doc.open") { + if matches!(id, "file.new" | "doc.open" | "doc.recover") { + self.menu.props = None; + sketch_attachment_ui::clear(self); + // The old document is already replaced: discard its editor without + // applying cancellation hooks (such as restoring its timeline marker). + self.dialog = None; + self.tool = None; + self.preview = preview::PreviewState::default(); self.cam = Camera::default(); self.fit_view(); } @@ -542,13 +560,13 @@ impl SolveApp { let Some(idx) = self.session.doc.feature_index(id) else { return }; let marker = self.session.doc.marker; self.tool = None; - if self.run("timeline.roll_to", json!({ "position": idx })).is_err() { + if dialogs::roll_marker(self, Some(idx)).is_err() { return; } match dialogs::for_feature(self, id, marker) { Some(d) => self.dialog = Some(d), None => { - let _ = self.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + let _ = dialogs::roll_marker(self, marker); self.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}"); self.ui.palette_open = true; } @@ -560,6 +578,13 @@ impl SolveApp { /// puts them back. Editing the sketch that is already open does nothing; editing another one /// finishes the current sketch first, so edit sessions never nest. pub fn edit_sketch(&mut self, id: u64) { + if let solvecraft_engine::doc::FaceAttachmentStatus::Lost { message, .. } = + self.session.placed_document().sketch_face_attachment(id, &self.session.model) + { + browser::redefine_sketch(self, id); + self.set_status(message, true); + return; + } if self.session.active_sketch == Some(id) { return; } @@ -615,7 +640,7 @@ impl SolveApp { pub fn origin_visible(&self) -> bool { self.ui.show_origin || (self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch)) - && !self.session.world_state().bodies.iter().any(|b| !self.session.visibility.hidden_bodies.contains(&b.name))) + && !self.session.world_state().bodies.iter().any(|b| self.session.body_visible(&b.name))) } pub fn set_status(&mut self, s: impl Into, error: bool) { diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 3829dd59..140bdcf8 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -13,6 +13,26 @@ fn sample_app() -> SolveApp { SolveApp::new(s, Services::default()) } +#[test] +fn properties_leave_with_the_document_but_survive_a_failed_open() { + let mut app = sample_app(); + app.menu.props = Some(("Prior body".into(), json!({"volume_mm3":42}))); + app.start("config.table"); + assert!(app.dialog.is_some()); + assert!(app.run("doc.open", json!({"path":"__missing_properties_regression__.solvecraft"})).is_err()); + assert!(app.menu.props.is_some()); + assert!(app.dialog.is_some()); + app.run("file.new", json!({"name":"New design"})).unwrap(); + assert!(app.menu.props.is_none()); + assert!(app.dialog.is_none()); + app.menu.props = Some(("New design".into(), json!({}))); + crate::documents::open_tab(&mut app, Session::default()).unwrap(); + assert!(app.menu.props.is_none()); + app.menu.props = Some(("Blank design".into(), json!({}))); + crate::documents::adopt(&mut app, Session::default()); + assert!(app.menu.props.is_none()); +} + fn find(items: &[Item], label: &str) -> Item { items .iter() @@ -45,6 +65,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 +100,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 +143,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(); @@ -245,10 +309,33 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() { fn menu_delete_items_use_the_same_path() { let mut app = sample_app(); let b = body(&app); - app.run("component.from_bodies", json!({"bodies": [b]})).unwrap(); + let original_volume = solvecraft_engine::kernel::measure(&app.session.world_state().body(&b).unwrap().body).unwrap().volume; + let created = app.run("component.from_bodies", json!({"bodies": [b]})).unwrap(); let c = app.session.doc.components[0].id; + let transfer = created["components"][0]["feature"].as_u64().unwrap(); + let transfer_name = app.session.doc.feature(transfer).unwrap().name.clone(); + let saved = app.session.doc.to_json(); act(&mut app, &Target::Component { id: c }, "Delete"); + let confirmation = app.menu.confirm.as_ref().expect("recorded transfer history needs confirmation"); + assert!(confirmation.report["deleted"].as_array().unwrap().iter().any(|name| name.as_str() == Some(transfer_name.as_str()))); + assert!(crate::delete::confirm(&mut app, false)); + assert_eq!(app.session.doc.to_json(), saved, "cancel keeps component and transfer history"); + act(&mut app, &Target::Component { id: c }, "Delete"); + assert!(crate::delete::confirm(&mut app, true)); assert!(app.session.doc.components.is_empty()); + assert!(app.session.doc.feature(transfer).is_none()); + let restored = app.session.world_state(); + assert_eq!(restored.bodies.len(), 1, "the original joined plate stays intact"); + let plate = restored.body(&b).unwrap(); + assert_eq!(plate.component, 0); + assert!((solvecraft_engine::kernel::measure(&plate.body).unwrap().volume - original_volume).abs() < 1e-6); + assert!(plate.mesh().contains(solvecraft_engine::geom::Vec3::new(40.0, 35.0, 12.0))); + let mut reopened = solvecraft_engine::Session::new(solvecraft_engine::doc::Document::from_json(&app.session.doc.to_json()).unwrap()); + reopened.refresh(); + let reopened_state = reopened.world_state(); + assert_eq!(reopened_state.bodies.len(), 1); + assert_eq!(reopened_state.body(&b).unwrap().component, 0); + assert!((solvecraft_engine::kernel::measure(&reopened_state.body(&b).unwrap().body).unwrap().volume - original_volume).abs() < 1e-6); // A sketch from the browser: asks, since its features go too. let id = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id; act(&mut app, &Target::Sketch { id }, "Delete"); diff --git a/crates/ui-egui/src/occurrence_ui_tests.rs b/crates/ui-egui/src/occurrence_ui_tests.rs new file mode 100644 index 00000000..27744544 --- /dev/null +++ b/crates/ui-egui/src/occurrence_ui_tests.rs @@ -0,0 +1,204 @@ +use crate::context_menu::{self, Target}; +use crate::scenario::Harness; +use serde_json::json; +use solvecraft_engine::Sel; + +#[test] +fn replaced_body_name_uses_evaluated_owner_in_browser_and_component_actions() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap(); + let transferred = h.app.run("component.from_bodies", json!({"bodies":["Block"]})).unwrap(); + let owner = transferred["components"][0]["component"].as_u64().unwrap(); + assert_eq!(context_menu::component_bodies(&h.app, owner), ["Block"]); + h.app.run("solid.remove", json!({"bodies":["Block"]})).unwrap(); + h.app.run("solid.box", json!({"corner":[30,0,0],"length":5,"width":5,"height":5,"body_name":"Block"})).unwrap(); + h.frames(2); + let state = h.app.session.model.state(); + let body = state.body("Block").unwrap(); + assert_eq!(h.app.session.doc.model_body_component(body), 0); + assert_eq!(context_menu::component_bodies(&h.app, 0), ["Block"]); + assert!(context_menu::component_bodies(&h.app, owner).is_empty()); + assert_eq!(super::entries(&h.app, 0, "bodies").iter().map(|entry| entry.key.as_str()).collect::>(), ["Block"]); + assert!(super::entries(&h.app, owner, "bodies").is_empty()); + let bounds = h.app.session.world_state().body("Block").unwrap().mesh().bounds(); + assert!((bounds.min.x - 30.0).abs() < 1e-9); + assert!((bounds.max.x - 35.0).abs() < 1e-9); +} + +fn fixture() -> (Harness, u64, u64, u64) { + let mut h = Harness::new(); + let part = h.app.run("component.create", json!({"name":"Block"})).unwrap(); + let component = part["component"].as_u64().unwrap(); + let first = part["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[50,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.frames(3); + (h, component, first, second) +} + +fn click(h: &mut Harness, path: &[u64], control: &str) { + let point = h.call("ui.at", json!({"occurrence":path,"control":control})); + assert_eq!(point["ok"], true, "{point}"); + let p = &point["result"]; + let result = h.call("ui.click", json!({"x":p[0],"y":p[1]})); + assert_eq!(result["ok"], true); +} + +#[test] +fn occurrence_second_row_selects_exact_path_and_menu_actions() { + let (mut h, component, first, second) = fixture(); + click(&mut h, &[second], "row"); + assert_eq!(h.app.session.selection, vec![Sel::Occurrence { path: vec![second] }]); + let bounds = crate::viewport::selection_bounds(&h.app).unwrap(); + assert!((bounds.center().x - 55.0).abs() < 1e-8); + let target = Target::Occurrence { path: vec![second] }; + let items = context_menu::items(&h.app, &target); + let independent = items.iter().find(|i| i.id == "component.paste_new").unwrap(); + assert_eq!(independent.label, "Paste New (Own Features)"); + assert_eq!(independent.params.as_ref().unwrap()["occurrence"], second); + assert_eq!(independent.params.as_ref().unwrap()["path"], json!([second])); + let properties = items.iter().find(|i| i.id == "ui.properties").unwrap(); + assert_eq!(properties.params.as_ref().unwrap()["occurrence"], second); + context_menu::run_item(&mut h.app, properties, egui::Pos2::ZERO); + assert!((h.app.menu.props.as_ref().unwrap().1["total"]["center_of_mass"][0].as_f64().unwrap() - 55.0).abs() < 1e-8); + let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap(); + context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO); + assert_eq!(h.app.tree.occurrence_move.as_ref().unwrap().path, vec![second]); + let ground = items.iter().find(|i| i.id == "occurrence.ground").unwrap(); + context_menu::run_item(&mut h.app, ground, egui::Pos2::ZERO); + assert!(h.app.session.doc.occurrences.iter().find(|o| o.id == second).unwrap().grounded); + assert!(!h.app.session.doc.occurrences.iter().find(|o| o.id == first).unwrap().grounded); + h.app.tree.occurrence_move = None; + click(&mut h, &[second], "activate"); + assert_eq!(h.app.session.active_component, component); + assert_eq!(h.app.session.active_occurrence_path, Some(vec![second])); +} + +#[test] +fn occurrence_eye_and_fold_are_independent_and_survive_rename() { + let (mut h, component, first, second) = fixture(); + click(&mut h, &[second], "eye"); + assert!(h.app.session.occurrence_hidden(&[second])); + assert!(h.app.session.occurrence_visible(&[first])); + let second_body = h.app.session.occurrence_bodies(&[second], true).unwrap()[0].clone(); + assert!(!h.app.session.body_visible(&second_body)); + click(&mut h, &[second], "fold"); + assert!(h.app.tree.collapsed.contains(&format!("o:{second}"))); + assert!(!h.app.tree.collapsed.contains(&format!("o:{first}"))); + h.app.run("component.rename", json!({"component":component,"name":"Renamed Block"})).unwrap(); + h.frames(3); + assert!(h.app.session.occurrence_hidden(&[second])); + click(&mut h, &[second], "eye"); + assert!(h.app.session.occurrence_visible(&[second])); +} + +#[test] +fn occurrence_move_dialog_keeps_other_pending_moves_and_reuses_initial_baseline() { + let (mut h, _, first, second) = fixture(); + h.app.run("occurrence.move", json!({"occurrence":first,"translate":[0,10,0],"capture":false})).unwrap(); + h.app.run("occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})).unwrap(); + let first_pending = h.app.session.pending_moves[&first]; + let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] }); + let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap(); + context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO); + let mut m = h.app.tree.occurrence_move.clone().unwrap(); + m.pivot = + Some(solvecraft_engine::doc::apply_point(&h.app.session.occurrence_placement(&[second]).unwrap().world, solvecraft_engine::geom::Vec3::ZERO)); + m.translate = [10.0, 0.0, 0.0]; + crate::browser::apply_occurrence_move(&mut h.app, &m); + let at = h.app.session.occurrence_placement(&[second]).unwrap(); + assert!((solvecraft_engine::doc::apply_point(&at.world, solvecraft_engine::geom::Vec3::ZERO).x - 65.0).abs() < 1e-8); + m.translate = [3.0, 0.0, 0.0]; + crate::browser::apply_occurrence_move(&mut h.app, &m); + let at = h.app.session.occurrence_placement(&[second]).unwrap(); + assert!((solvecraft_engine::doc::apply_point(&at.world, solvecraft_engine::geom::Vec3::ZERO).x - 58.0).abs() < 1e-8); + assert_eq!(h.app.session.pending_moves[&first], first_pending); + crate::browser::revert_occurrence_dialog(&mut h.app, &m); + let restored = h.app.session.occurrence_placement(&[second]).unwrap(); + assert!((solvecraft_engine::doc::apply_point(&restored.world, solvecraft_engine::geom::Vec3::ZERO).x - 55.0).abs() < 1e-8); + assert_eq!(h.app.session.pending_moves[&first], first_pending); + h.app.run("component.revert_position", json!({"occurrences":[second]})).unwrap(); + context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO); + let mut fresh = h.app.tree.occurrence_move.clone().unwrap(); + assert!(!fresh.had_pending); + fresh.translate = [2.0, 0.0, 0.0]; + crate::browser::apply_occurrence_move(&mut h.app, &fresh); + crate::browser::revert_occurrence_dialog(&mut h.app, &fresh); + assert!(!h.app.session.pending_moves.contains_key(&second)); + assert_eq!(h.app.session.pending_moves[&first], first_pending); +} + +#[test] +fn occurrence_delete_targets_second_copy_even_if_first_was_selected() { + let (mut h, _, first, second) = fixture(); + click(&mut h, &[first], "row"); + let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] }); + let delete = items.iter().find(|i| i.id == "ui.delete").unwrap(); + context_menu::run_item(&mut h.app, delete, egui::Pos2::ZERO); + crate::delete::confirm(&mut h.app, true); + assert!(h.app.session.doc.occurrences.iter().any(|o| o.id == first)); + assert!(!h.app.session.doc.occurrences.iter().any(|o| o.id == second)); + assert_eq!(h.app.session.world_state().bodies.len(), 1); +} + +#[test] +fn occurrence_new_component_keeps_inactive_second_parent_authoring_context() { + let (mut h, _, first, second) = fixture(); + assert_eq!(h.app.session.active_component, 0); + let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] }); + let create = items.iter().find(|i| i.id == "component.create").unwrap(); + assert_eq!(create.params.as_ref().unwrap()["parent_path"], json!([second])); + context_menu::run_item(&mut h.app, create, egui::Pos2::ZERO); + let active = h.app.session.active_occurrence_path.as_ref().unwrap(); + assert_eq!(active.len(), 2); + assert_eq!(active[0], second); + assert_ne!(active[0], first); + h.app.run("solid.box", json!({"corner":[51,1,1],"length":2,"width":2,"height":2})).unwrap(); + let feature = h.app.session.model.state().bodies.last().unwrap().feature; + let mut centers: Vec = + h.app.session.world_state().bodies.iter().filter(|b| b.feature == feature).map(|b| b.mesh().bounds().center().x).collect(); + centers.sort_by(f64::total_cmp); + assert_eq!(centers.len(), 2); + assert!((centers[0] - 2.0).abs() < 1e-8 && (centers[1] - 52.0).abs() < 1e-8, "{centers:?}"); +} + +#[test] +fn occurrence_body_appearance_editor_inherits_definition_precision() { + let (mut h, _, _, second) = fixture(); + h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#123456","appearance":"Tinted source","opacity":0.123456})).unwrap(); + let name = h.app.session.occurrence_bodies(&[second], false).unwrap()[0].clone(); + h.app.session.selection = vec![Sel::Body { name }]; + let (kind, _) = crate::dialogs_appearance::start(&h.app, "appearance.assign").unwrap(); + let crate::dialogs::Kind::Appearance(look) = kind else { panic!("appearance editor") }; + assert_eq!(look.color, [0x12, 0x34, 0x56]); + assert_eq!(look.opacity, 0.123456); + assert_eq!(look.look.as_deref(), Some("Tinted source")); +} + +#[test] +fn occurrence_nested_shared_child_has_distinct_rows_and_scope() { + let mut h = Harness::new(); + let parent = h.app.run("component.create", json!({"name":"Assembly"})).unwrap(); + let parent_component = parent["component"].as_u64().unwrap(); + let parent_occurrence = parent["occurrence"].as_u64().unwrap(); + let child = h.app.run("component.create", json!({"name":"Child"})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + let other = h.app.run("occurrence.copy", json!({"component":parent_component,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.frames(3); + click(&mut h, &[other, child], "row"); + assert_eq!(h.app.session.selection, vec![Sel::Occurrence { path: vec![other, child] }]); + assert!((crate::viewport::selection_bounds(&h.app).unwrap().center().x - 85.0).abs() < 1e-8); + let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![other, child] }); + assert!(items.iter().any(|i| i.label.starts_with("Shared child:"))); + click(&mut h, &[other, child], "fold"); + assert!(h.app.tree.collapsed.contains(&format!("o:{other}/{child}"))); + assert!(!h.app.tree.collapsed.contains(&format!("o:{parent_occurrence}/{child}"))); + click(&mut h, &[other, child], "eye"); + assert!(h.app.session.occurrence_visible(&[parent_occurrence, child])); + assert!(!h.app.session.occurrence_visible(&[other, child])); + let delete = items.iter().find(|i| i.id == "ui.delete").unwrap(); + context_menu::run_item(&mut h.app, delete, egui::Pos2::ZERO); + assert!(h.app.menu.confirm.is_some(), "shared child deletion must communicate scope before removing both renderings"); +} diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index afc2ea45..05a81df7 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -8,12 +8,239 @@ use solvecraft_engine::{Sel, Session}; use crate::dialogs::{Dialog, Kind, apply_commands}; use crate::{Services, SolveApp}; +#[test] +fn cylinder_emboss_empty_target_face_applies_automatic_wrap_and_deboss() { + for deboss in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap(); + let initial = h.app.session.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + let sketch = + h.app.run("sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap()["sketch"] + .as_u64() + .unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }]; + h.app.start("solid.emboss"); + h.frames(2); + if deboss { + h.settle(); + h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Type:Deboss"}})).unwrap(); + } + let dialog = h.app.dialog.as_ref().unwrap(); + assert!(dialog.inputs[1].items.is_empty()); + let commands = apply_commands(&h.app, dialog).unwrap(); + assert!(commands[0].1.get("face").is_none()); + let preview = h.app.session.preview_world(&commands).unwrap(); + let expected = if deboss { -19.0 } else { 21.0 }; + let preview_delta = preview.after.body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - initial; + assert!( + (preview_delta - expected).abs() < 0.05, + "deboss={deboss}, preview delta={preview_delta}, expected={expected}, commands={commands:?}" + ); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + let feature = h.app.session.doc.features.last().unwrap().clone(); + let solvecraft_engine::doc::FeatureKind::Emboss { face: Some(face), deboss: actual, .. } = &feature.kind else { + panic!("automatic Apply must save its cylinder anchor: {:?}", feature.kind) + }; + assert_eq!(*actual, deboss); + assert_eq!(face.body, "Sleeve"); + assert!( + (h.app.session.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - initial - expected) + .abs() + < 0.05 + ); + h.app.edit_feature(feature.id); + h.frames(2); + assert!(matches!(h.app.dialog.as_ref().unwrap().inputs[1].items.first(), Some(Sel::Face { body, .. }) if body == "Sleeve")); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.session.doc.feature(feature.id).unwrap().kind, feature.kind); + } +} + +#[test] +fn cylinder_emboss_target_face_and_edit_keep_the_anchored_operation() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap(); + let sketch = h.app.run("sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap()["sketch"] + .as_u64() + .unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }]; + h.app.start("solid.emboss"); + h.frames(2); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1); + let planar = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert!(planar[0].1.get("face").is_none()); + h.call("ui.dialogInput", json!({"index":1})); + h.call("ui.view", json!({"view":"back"})); + h.frames(2); + h.step(&json!({"click":{"world":[0,10,7.5]}})).unwrap(); + let dialog = h.app.dialog.as_ref().unwrap(); + assert!(matches!(dialog.inputs[1].items.first(), Some(Sel::Face { body, .. }) if body == "Sleeve")); + let commands = apply_commands(&h.app, dialog).unwrap(); + assert_eq!(commands[0].1["targets"], json!(["Sleeve"])); + assert!(commands[0].1.get("face").is_some()); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + let feature = h.app.session.doc.features.last().unwrap().id; + let before = serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap(); + h.app.edit_feature(feature); + h.frames(2); + let edited = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(edited[0].0, "timeline.redefine"); + assert_eq!(edited[0].1["params"]["targets"], json!(["Sleeve"])); + assert!(edited[0].1["params"].get("face").is_some()); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + assert_eq!(serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap(), before); +} + +#[test] +fn cylinder_emboss_edit_retains_second_source_and_author_paths_and_all_profiles() { + let mut h = crate::scenario::Harness::new(); + let source = h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap(); + h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap(); + h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap(); + let source_second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("component.activate", json!({"component":"root"})).unwrap(); + let author = h.app.run("component.create", json!({"name":"Author"})).unwrap()["component"].as_u64().unwrap(); + h.app.run("occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]})).unwrap(); + let author_second = h.app.run("occurrence.copy", json!({"component":author,"translate":[0,100,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("component.activate", json!({"component":author,"occurrence":author_second})).unwrap(); + h.app.run("sketch.create", json!({"plane":{"origin":[120,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let target = h.app.session.occurrence_bodies(&[source_second], false).unwrap().into_iter().next().unwrap(); + let feature = h.app.run("solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[120,0,7.5],"depth":"1mm","targets":[target]})).unwrap() + ["feature"] + .as_u64() + .unwrap(); + let before = h.app.session.doc.feature(feature).unwrap().clone(); + let solvecraft_engine::doc::FeatureKind::Emboss { face: Some(face), .. } = &before.kind else { panic!("selected face") }; + assert_eq!(face.path, vec![source_second]); + assert_eq!(face.author_path, vec![author_second]); + h.app.run("component.activate", json!({"component":"root"})).unwrap(); + h.app.edit_feature(feature); + h.frames(2); + let dialog = h.app.dialog.as_ref().unwrap(); + let Sel::Face { body, point, .. } = &dialog.inputs[1].items[0] else { panic!("refilled face") }; + assert_eq!(body, &target); + assert!(point.dist(Vec3::new(120.0, 0.0, 7.5)) < 1e-8); + let commands = apply_commands(&h.app, dialog).unwrap(); + assert_eq!(commands[0].1["params"]["profiles"], json!("all")); + let Sel::Profile { sketch, index } = dialog.inputs[0].items[0] else { panic!("refilled profile") }; + let plane = crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap(); + assert!(plane.origin.dist(Vec3::new(120.0, 0.0, 0.0)) < 1e-8, "{plane:?}"); + let mut guide = crate::gpu::GpuScene::default(); + crate::viewport::profile_fill(&mut guide, &h.app, sketch, index, egui::Color32::BLUE, egui::Color32::BLUE); + let (vertices, remainder) = guide.trans.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(remainder.is_empty() && !vertices.is_empty()); + let lift = h.app.cam.back() * (h.app.cam.half_height() * 0.002); + for vertex in vertices { + let coordinate = |offset| f32::from_le_bytes(vertex[offset..offset + 4].try_into().unwrap()) as f64; + let point = Vec3::new(coordinate(0), coordinate(4), coordinate(8)) - lift; + assert!((point.x - 120.0).abs() < 1e-4 && point.y.abs() < 2.01 && (4.99..=10.01).contains(&point.z), "{point:?}"); + } + let preview = h.app.session.preview_world(&commands).unwrap(); + let volume = preview.after.body(&target).unwrap().mesh().measure().volume; + assert!(volume > preview.before.body(&target).unwrap().mesh().measure().volume + 10.0); + let colors = crate::preview::Colors::from_tokens(&crate::theme::Tokens::get()); + let built = crate::preview::compute(&h.app.session, &commands, colors).unwrap(); + assert!(built.replaced.contains(&target) && !built.scene.tris.is_empty(), "{:?}", built.replaced); + let source_first = h.app.session.doc.occurrence_of(source).unwrap().id; + h.app.run("browser.visibility", json!({"paths":[[source_first]],"visible":false})).unwrap(); + let hidden = crate::preview::compute(&h.app.session, &commands, colors).unwrap(); + assert_eq!(hidden.replaced, vec![target.clone()]); + let visible = h.app.session.preview_visible_world(&commands).unwrap(); + assert!(visible.before.body("Sleeve").is_none() && visible.after.body("Sleeve").is_none()); + assert!(visible.after.body(&target).is_some() && !hidden.scene.tris.is_empty()); + let mut shared = h.app.session.scratch(); + shared.execute("component.activate", &json!({"component":source,"occurrence":source_first})).unwrap(); + let new_body = shared + .preview_visible_world(&[("solid.box".into(), json!({"body_name":"NewPreview","length":2,"width":2,"height":2,"operation":"new"}))]) + .unwrap(); + let added = new_body.after.bodies.iter().filter(|body| new_body.before.body(&body.name).is_none()).collect::>(); + assert_eq!(added.len(), 1, "{:?}", added.iter().map(|body| &body.name).collect::>()); + assert!(added[0].name.contains("Source:2"), "{}", added[0].name); + let doc = h.app.session.doc.to_json(); + let history = (h.app.session.undo.len(), h.app.session.redo.len()); + let selection = h.app.session.selection.clone(); + let Kind::Emboss { depth, .. } = &mut h.app.dialog.as_mut().unwrap().kind else { panic!("Emboss editor") }; + *depth = "2 mm".into(); + let changed = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let changed_preview = h.app.session.preview_world(&changed).unwrap(); + assert!(changed_preview.after.body(&target).unwrap().mesh().measure().volume > volume + 10.0); + let mut invalid = changed.clone(); + invalid[0].1["params"]["depth"] = json!("unknown_depth"); + assert!(h.app.session.preview_world(&invalid).is_err()); + assert_eq!(h.app.session.doc.to_json(), doc); + assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history); + assert_eq!(h.app.session.selection, selection); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.session.doc.feature(feature).unwrap().kind, before.kind); + h.app.edit_feature(feature); + h.frames(2); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + let after = h.app.session.doc.feature(feature).unwrap(); + assert_eq!(after.kind, before.kind); + assert_eq!(after.face_names, before.face_names); + assert_eq!(h.app.session.active_component, 0); +} + fn app_with(script: serde_json::Value) -> SolveApp { let mut s = Session::default(); s.run_script(&script).unwrap(); SolveApp::new(s, Services::default()) } +#[test] +fn cylinder_emboss_profile_guide_uses_target_or_unique_foreign_sketch_frame() { + for target_owned in [true, false] { + let mut h = crate::scenario::Harness::new(); + let source = if target_owned { h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap() } else { 0 }; + h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap(); + let (profile_owner, expected, target, face) = if target_owned { + h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap(); + let second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + let target = h.app.session.occurrence_bodies(&[second], false).unwrap().remove(0); + (source, Vec3::new(120.0, 0.0, 0.0), target, json!([120, 0, 7.5])) + } else { + let profile = h.app.run("component.create", json!({"name":"Profile"})).unwrap()["component"].as_u64().unwrap(); + h.app.run("occurrence.move", json!({"occurrence":"Profile:1","axis":[0,0,1],"angle":"90 deg","translate":[50,20,0]})).unwrap(); + (profile, Vec3::new(0.0, 10.0, 0.0), "Sleeve".to_string(), json!([0, 10, 7.5])) + }; + let plane = if target_owned { + json!({"origin":[40,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]}) + } else { + json!({"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}) + }; + let sketch = h.app.run("sketch.create", json!({"plane":plane,"name":"Badge"})).unwrap()["sketch"].as_u64().unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("component.activate", json!({"component":"root"})).unwrap(); + let feature = + h.app.run("solid.emboss", json!({"sketch":sketch,"profiles":"all","face":face,"depth":"1mm","targets":[target]})).unwrap()["feature"] + .as_u64() + .unwrap(); + h.app.edit_feature(feature); + h.frames(2); + assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap().origin.dist(expected) < 1e-8); + if !target_owned { + h.app.run("occurrence.copy", json!({"component":profile_owner,"translate":[100,0,0]})).unwrap(); + assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap_err().contains("ambiguous")); + let mut guide = crate::gpu::GpuScene::default(); + crate::viewport::profile_fill(&mut guide, &h.app, sketch, 0, egui::Color32::BLUE, egui::Color32::BLUE); + assert!(guide.trans.is_empty() && guide.lines.is_empty()); + } + } +} + fn boxed() -> SolveApp { app_with(json!([{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}])) } diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index e9ef862e..97a01398 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -103,6 +103,28 @@ impl PreviewState { Err(e) => self.error = Some(e), } } + + /// Errors validate only the current inputs. Keep the last good geometry while + /// computing new inputs, but never let an obsolete result block their Apply. + fn poll(&mut self, key: u64) { + if self.shown_key != Some(key) { + self.error = None; + } + if let Some((k, rx)) = &self.running { + let k = *k; + let job = match rx.try_recv() { + Ok(job) => Some(job), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => Some(Err("the preview failed".into())), + }; + if let Some(job) = job { + self.running = None; + if k == key { + self.apply(k, job); + } + } + } + } } /// Keep the preview in step with the open dialog: start a computation when its inputs changed, @@ -110,6 +132,7 @@ impl PreviewState { pub fn update(app: &mut SolveApp, ctx: &egui::Context) { let cmds = match app.dialog.as_ref().filter(|d| d.previews()).map(|d| crate::dialogs::preview_commands(app, d)) { Some(Ok(c)) if !c.is_empty() => c, + _ if crate::sketch_attachment_ui::commands(app).is_some() => crate::sketch_attachment_ui::commands(app).unwrap_or_default(), // A parameter being edited in Change Parameters. _ if app.params.as_ref().and_then(|p| p.preview_commands()).is_some() => { app.params.as_ref().and_then(|p| p.preview_commands()).unwrap_or_default() @@ -127,20 +150,7 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) { format!("{colors:?}").hash(&mut h); let key = h.finish(); let pv = &mut app.preview; - if let Some((k, rx)) = &pv.running { - let k = *k; - match rx.try_recv() { - Ok(job) => { - pv.running = None; - pv.apply(k, job); - } - Err(TryRecvError::Empty) => {} - Err(TryRecvError::Disconnected) => { - pv.running = None; - pv.apply(k, Err("the preview failed".into())); - } - } - } + pv.poll(key); if pv.running.is_none() && pv.shown_key != Some(key) { pv.started = crate::now_ms(); let scratch = app.session.scratch(); @@ -176,6 +186,15 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V /// show their result in place of the bodies they change. When the feature fails (a boolean the /// kernel can't do yet), the tool body is still shown, with the error. pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { + if cmds.iter().any(|(command, _)| command == "sketch.redefine") { + let preview = s.preview_visible_world(cmds).map_err(|error| error.to_string())?; + return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut built = build(&preview.before, &preview.after, colors); + built.bounds = state_bounds(&preview.after); + built + })) + .map_err(|_| "the preview failed".to_string()); + } // Joints and drives move occurrences: shown on the placed model. if let Some(job) = crate::dialogs_assembly::world_preview(s, cmds, colors) { return job; @@ -367,6 +386,41 @@ mod tests { Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } } + #[test] + fn correcting_inputs_clears_old_errors_and_discards_late_old_jobs() { + let mut pv = PreviewState { + shown_key: Some(1), + error: Some("unknown_angle".into()), + active: true, + replaced: vec!["Turn".into()], + key: 9, + ..PreviewState::default() + }; + let (tx, rx) = std::sync::mpsc::channel(); + pv.running = Some((1, rx)); + pv.poll(2); + assert!(pv.error.is_none(), "the corrected inputs can Apply while their preview computes"); + assert!(pv.running.is_some()); + assert!(pv.active); + assert_eq!(pv.replaced, ["Turn"]); + assert_eq!(pv.key, 9, "the last good geometry stays visible"); + tx.send(Err("unknown_angle".into())).unwrap(); + pv.poll(2); + assert!(pv.error.is_none(), "a late obsolete result cannot disable Apply again"); + assert!(pv.running.is_none()); + assert_eq!(pv.shown_key, Some(1)); + assert_eq!(pv.key, 9); + + let (tx, rx) = std::sync::mpsc::channel(); + pv.running = Some((2, rx)); + tx.send(Err("current invalid expression".into())).unwrap(); + pv.poll(2); + assert_eq!(pv.error.as_deref(), Some("current invalid expression")); + assert_eq!(pv.shown_key, Some(2)); + pv.poll(2); + assert_eq!(pv.error.as_deref(), Some("current invalid expression"), "current errors still prevent invalid Apply"); + } + fn count(bytes: &[u8], stride: usize, col: [u8; 4]) -> usize { bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count() } diff --git a/crates/ui-egui/src/revolve_edit_tests.rs b/crates/ui-egui/src/revolve_edit_tests.rs new file mode 100644 index 00000000..be452ca7 --- /dev/null +++ b/crates/ui-egui/src/revolve_edit_tests.rs @@ -0,0 +1,240 @@ +use crate::scenario::Harness; +use serde_json::{Value, json}; +use solvecraft_engine::doc::{AxisRef, FeatureKind}; + +fn make(h: Harness, axis: Value) -> Harness { + make_params(h, axis, json!({})) +} +fn make_params(mut h: Harness, axis: Value, extra: Value) -> Harness { + h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let mut params = json!({"sketch":"Profile","axis":axis,"angle":"90 deg","operation":"new","name":"Turn","body_name":"Main"}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + h.app.run("solid.revolve", params).unwrap(); + h.frames(4); + h +} + +#[test] +fn unchanged_all_profiles_retains_future_geometry_but_reselection_is_explicit() { + for reselect in [false, true] { + let mut h = make(Harness::new(), json!("Y")); + open(&mut h); + if reselect { + let selection = h.app.dialog.as_ref().unwrap().inputs[0].items[0].clone(); + let mut dialog = h.app.dialog.take().unwrap(); + dialog.active = 0; + dialog.pick(&h.app.session, selection.clone()); + dialog.pick(&h.app.session, selection); + h.app.dialog = Some(dialog); + h.frames(4); + } + h.step(&json!({"widget":"OK"})).unwrap(); + let FeatureKind::Revolve { profiles, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All), !reselect); + h.app.run("sketch.edit", json!({"sketch":"Profile"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let state = h.app.session.world_state(); + let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum(); + let expected = if reselect { 490.873852 } else { 1374.446786 }; + assert!((volume - expected).abs() < 0.3, "{volume} != {expected}"); + } +} + +#[test] +fn to_object_noop_preserves_cut_targets_and_untargeted_body() { + let mut h = Harness::new(); + for name in ["A", "B"] { + h.app.run("solid.box", json!({"length":20,"width":5,"height":20,"corner":[0,0,0],"body_name":name})).unwrap(); + } + let mut h = make_params(h, json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","operation":"cut","targets":["A"]})); + let state = h.app.session.world_state(); + // A quarter annulus separates the stock into inner and outer solids. Both are + // results of cutting A; checking only the first named solid loses material. + let cut_volume = |state: &solvecraft_engine::doc::ModelState| -> f64 { + state.bodies.iter().filter(|b| b.name != "B").map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum() + }; + let before_a = cut_volume(&state); + let removed = 0.5 * (15.0f64.powi(2) - 10.0f64.powi(2)) * 5.0 * std::f64::consts::FRAC_PI_2; + assert!((before_a - (2000.0 - removed)).abs() < 0.2, "cut results {:?}: {before_a}", state.bodies.iter().map(|b| &b.name).collect::>()); + let in_cut_stock = |p| state.bodies.iter().filter(|b| b.name != "B").any(|b| b.body.contains(p)); + assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(2.0, 2.0, 2.0))); + assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(18.0, 2.0, 18.0))); + assert!(!in_cut_stock(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0))); + assert!(state.body("B").unwrap().body.contains(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0))); + let before_bounds: Vec<_> = state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); + let undo = h.app.session.undo.len(); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none()); + let state = h.app.session.world_state(); + assert!((solvecraft_engine::doc::kernel::measure(&state.body("B").unwrap().body).unwrap().volume - 2000.0).abs() < 1e-7); + assert!((cut_volume(&state) - before_a).abs() < 1e-7); + assert_eq!(state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::>(), before_bounds); + assert_eq!(h.app.session.undo.len(), undo + 1); + let FeatureKind::Revolve { targets, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(targets, &["A"]); +} + +#[test] +fn to_object_noop_retains_dormant_secondary_angle_and_downstream_dimension() { + let mut h = make_params(Harness::new(), json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","angle2":"30 deg","direction":"symmetric"})); + h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d2 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap(); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone(); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names); + assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI / 6.0).abs() < 1e-8); +} + +#[test] +fn rotated_owner_to_point_remains_exact_on_noop_edit() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"})); + h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap(); + h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap(); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds); +} + +#[test] +fn second_active_occurrence_noop_preserves_definition_axis_target_and_instances() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"})); + let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone(); + h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"17 deg"})).unwrap(); + h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap(); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let frame = h.app.session.active_component_frame(); + let expected_origin = solvecraft_engine::doc::apply_point(&frame, solvecraft_engine::geom::Vec3::ZERO); + let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); + open(&mut h); + let (origin, _) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert!((origin - expected_origin).len() < 1e-8); + assert!(origin.len() > 50.0, "fixture must distinguish first and second placements"); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview_world(&commands).unwrap(); + let placed = preview.after.bodies.iter().find(|body| body.mesh().bounds().center().x > 50.0).unwrap(); + assert!(placed.mesh().triangles.len() > 10); + assert_eq!(placed.mesh().bounds(), before.iter().find(|(_, bounds)| bounds.center().x > 50.0).unwrap().1); + assert!(h.widget("↖ Custom Axis").is_some()); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); + assert_eq!(after, before); + assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&vec![second.as_u64().unwrap()])); +} +fn axis(h: &Harness) -> Value { + let FeatureKind::Revolve { axis, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + serde_json::to_value(axis).unwrap() +} +fn open(h: &mut Harness) { + h.step(&json!({"call":"ui.editFeature","params":{"feature":"Turn"}})).unwrap(); + h.frames(8); +} + +#[test] +fn actual_noop_editor_retains_all_revolve_axis_kinds_and_analytic_volume() { + for axis_value in [json!("Y"), json!("y"), json!("l4"), json!({"origin":[0,0,0],"dir":[0,2,0]})] { + let mut h = make(Harness::new(), axis_value.clone()); + let original = axis(&h); + let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone(); + let state = h.app.session.world_state(); + let body = state.body("Main").unwrap(); + let bounds = body.mesh().bounds(); + let radii = if axis_value == json!("l4") { 25.0 } else { 225.0 - 100.0 }; + let expected = 0.5 * radii * 5.0 * std::f64::consts::FRAC_PI_2; + let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume; + assert!((volume - expected).abs() < 0.03, "{axis_value}: {volume}"); + open(&mut h); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview(&commands).unwrap(); + assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{axis_value}: {:?}", h.app.session.log); + assert_eq!(axis(&h), original); + assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds); + } +} + +#[test] +fn revolve_angle_edit_preserves_axis_downstream_binding_and_one_undo() { + let mut h = make(Harness::new(), json!({"origin":[0,0,0],"dir":[0,2,0]})); + h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d1 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap(); + let original = axis(&h); + let undo = h.app.session.undo.len(); + open(&mut h); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"180 deg"})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(axis(&h), original); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI).abs() < 1e-8); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(axis(&h), original); + assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::FRAC_PI_2).abs() < 1e-8); +} + +#[test] +fn rotated_owner_explicit_axis_is_lossless_with_another_component_active() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make(h, json!({"origin":[0,0,0],"dir":[0,2,0]})); + h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap(); + h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap(); + let active = h.app.session.active_component; + let original = axis(&h); + let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + open(&mut h); + let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8); + let angle = 17.0f64.to_radians(); + assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(axis(&h), original); + assert_eq!(h.app.session.active_component, active); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds); +} + +#[test] +fn world_and_sketch_axis_handles_follow_authoring_occurrence() { + for selected in [json!("Y"), json!("y")] { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = make(h, selected); + h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap(); + let original = axis(&h); + open(&mut h); + let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let angle = 17.0f64.to_radians(); + assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8); + assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(axis(&h), original); + } +} + +#[test] +fn sketch_axis_cancel_restores_original_document_and_history() { + let mut h = make(Harness::new(), json!("y")); + let original = serde_json::to_value(&*h.app.session.doc).unwrap(); + let undo = h.app.session.undo.len(); + open(&mut h); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"180 deg"})).unwrap(); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original); + assert_eq!(h.app.session.undo.len(), undo); + assert!(matches!(&h.app.session.doc.find_feature("Turn").unwrap().kind,FeatureKind::Revolve{axis:AxisRef::SketchAxis{axis},..} if axis=="y")); +} diff --git a/crates/ui-egui/src/revolve_manipulator_tests.rs b/crates/ui-egui/src/revolve_manipulator_tests.rs new file mode 100644 index 00000000..d62d4f07 --- /dev/null +++ b/crates/ui-egui/src/revolve_manipulator_tests.rs @@ -0,0 +1,366 @@ +use crate::scenario::Harness; +use serde_json::{Value, json}; +use solvecraft_engine::doc::{FeatureKind, kernel}; + +fn fixture(mut h: Harness, extra: Value) -> Harness { + h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let mut params = json!({"sketch":"Profile","axis":"Y","angle":"90 deg","operation":"new","name":"Turn","body_name":"Main"}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + h.app.run("solid.revolve", params).unwrap(); + h.frames(4); + h +} +fn open(h: &mut Harness) { + h.step(&json!({"call":"ui.view","params":{"view":"front"}})).unwrap(); + h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap(); + h.step(&json!({"call":"ui.scroll","params":{"x":700,"y":450,"delta":-400}})).unwrap(); + h.step(&json!({"call":"ui.editFeature","params":{"feature":"Turn"}})).unwrap(); + h.frames(8); +} +fn at_angle(degrees: f64) -> Value { + let a = degrees.to_radians(); + json!([12.5 * a.cos(), 2.5, 12.5 * a.sin()]) +} +fn handle_at(h: &mut Harness, name: &str, world: Value) { + let actual = h.at(&json!({"handle":name})).unwrap(); + let expected = h.at(&json!({"world":world})).unwrap(); + assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1.0, "{actual:?} != {expected:?}"); +} +fn volume(h: &Harness) -> f64 { + kernel::measure(&h.app.session.world_state().body("Main").unwrap().body).unwrap().volume +} +fn angle(h: &Harness) -> String { + let FeatureKind::Revolve { angle, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + angle.clone() +} +fn expected_volume(degrees: f64) -> f64 { + 0.5 * (225.0 - 100.0) * 5.0 * degrees.to_radians() +} + +#[test] +fn actual_positive_negative_axis_and_negative_sweep_drags_follow_geometry() { + for (params, sign) in [(json!({}), 1.0), (json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]}}), 1.0), (json!({"angle":"-90 deg"}), -1.0)] { + let mut h = fixture(Harness::new(), params.clone()); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone(); + let undo = h.app.session.undo.len(); + open(&mut h); + handle_at(&mut h, "revolve", at_angle(sign * 90.0)); + h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(sign * 60.0)}]})).unwrap(); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview_world(&commands).unwrap(); + assert!((kernel::measure(&preview.after.body("Main").unwrap().body).unwrap().volume - expected_volume(60.0)).abs() < 0.03); + let bounds = preview.after.body("Main").unwrap().mesh().bounds(); + if sign > 0.0 { + assert!(bounds.min.z > -1e-8 && bounds.max.z > 12.9); + } else { + assert!(bounds.max.z < 1e-6 && bounds.min.z < -12.9, "{params}: {bounds:?}"); + } + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(angle(&h), if params.get("angle").is_some() { "-60 deg" } else { "60 deg" }); + assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names); + assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + } +} + +#[test] +fn symmetric_boundary_drags_edit_included_angle_and_cancel_is_lossless() { + for (handle, sign) in [("revolve", 1.0), ("revolve2", -1.0)] { + let mut h = fixture(Harness::new(), json!({"angle":"120 deg","direction":"symmetric"})); + let original = serde_json::to_value(&*h.app.session.doc).unwrap(); + let undo = h.app.session.undo.len(); + open(&mut h); + handle_at(&mut h, "revolve", at_angle(60.0)); + handle_at(&mut h, "revolve2", at_angle(-60.0)); + h.step(&json!({"drag":[{"handle":handle},{"world":at_angle(sign * 45.0)}]})).unwrap(); + let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") }; + assert_eq!(entered, "90 deg"); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original); + assert_eq!(h.app.session.undo.len(), undo); + open(&mut h); + h.step(&json!({"drag":[{"handle":handle},{"world":at_angle(sign * 45.0)}]})).unwrap(); + let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") }; + assert_eq!(entered, "90 deg", "second {handle} drag"); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1, "{handle}: {} angle {}", volume(&h), angle(&h)); + let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + assert!((bounds.max.z - 15.0 / 2.0f64.sqrt()).abs() < 0.03 && (bounds.min.z + 15.0 / 2.0f64.sqrt()).abs() < 0.03); + let path = h.dir.join("symmetric.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + assert_eq!(angle(&h), "90 deg"); + assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1); + } +} + +#[test] +fn to_object_displays_derived_sweep_without_dormant_edit_until_angle_selected() { + let mut h = fixture(Harness::new(), json!({"angle":"45 deg","angle2":"30 deg","to":[0,2,12]})); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + open(&mut h); + assert!(h.widget("Sweep 90.0°").is_some()); + assert!(crate::scenario::handle_at("revolve").is_none()); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"180 deg"})).unwrap(); + let crate::dialogs::Kind::Revolve { angle, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") }; + assert_eq!(angle, "45 deg"); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + open(&mut h); + h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Type:Angle"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"90 deg"})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + let FeatureKind::Revolve { to, angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert!(to.is_none()); + assert_eq!(angle2.as_deref(), Some("30 deg")); + assert!((volume(&h) - expected_volume(120.0)).abs() < 0.03); +} + +#[test] +fn two_side_boundary_preserves_other_angle_and_parameter_binding() { + let mut h = fixture(Harness::new(), json!({"angle":"60 deg","angle2":"30 deg"})); + h.app.run("solid.box", json!({"length":1,"width":1,"height":"(d2 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap(); + let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone(); + open(&mut h); + handle_at(&mut h, "revolve", at_angle(60.0)); + handle_at(&mut h, "revolve2", at_angle(-30.0)); + h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at_angle(-45.0)}]})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + let FeatureKind::Revolve { angle, angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(angle, "60 deg"); + assert_eq!(angle2.as_deref(), Some("45 deg")); + assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names); + assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::FRAC_PI_4).abs() < 1e-8); + assert!((volume(&h) - expected_volume(105.0)).abs() < 0.03); +} + +#[test] +fn selected_second_placement_custom_axis_handle_and_preview_stay_in_its_frame() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let mut h = fixture(h, json!({"axis":{"origin":[0,0,0],"dir":[0,2,0]}})); + let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone(); + h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"17 deg"})).unwrap(); + h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap(); + let frame = h.app.session.active_component_frame(); + let world = |degrees: f64| { + let a = degrees.to_radians(); + let p = solvecraft_engine::doc::apply_point(&frame, solvecraft_engine::geom::Vec3::new(12.5 * a.cos(), 2.5, 12.5 * a.sin())); + json!([p.x, p.y, p.z]) + }; + let axis = match &h.app.session.doc.find_feature("Turn").unwrap().kind { + FeatureKind::Revolve { axis, .. } => axis.clone(), + _ => panic!("revolve"), + }; + open(&mut h); + assert!(h.widget("↖ Custom Axis").is_some()); + handle_at(&mut h, "revolve", world(90.0)); + h.step(&json!({"drag":[{"handle":"revolve"},{"world":world(60.0)}]})).unwrap(); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview_world(&commands).unwrap(); + let placed = preview.after.bodies.iter().find(|body| body.mesh().bounds().center().x > 50.0).unwrap(); + assert!((kernel::measure(&placed.body).unwrap().volume - expected_volume(60.0)).abs() < 0.03); + h.step(&json!({"widget":"OK"})).unwrap(); + let FeatureKind::Revolve { axis: after, angle, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(after, &axis); + assert_eq!(angle, "60 deg"); + assert_eq!(h.app.session.active_occurrence_path.as_deref(), Some(&[second.as_u64().unwrap()][..])); +} + +#[test] +fn curved_to_object_has_surface_feedback_without_an_angular_handle_or_input() { + let mut h = Harness::new(); + h.app.run("solid.cylinder", json!({"radius":3,"height":5,"base":[0,0,12],"axis":"Y","operation":"new","body_name":"Target"})).unwrap(); + let mut h = fixture(h, json!({})); + open(&mut h); + h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Type:To Object"}})).unwrap(); + let mut dialog = h.app.dialog.take().unwrap(); + dialog.active = dialog.inputs.iter().position(|input| input.label == "To Object").unwrap(); + dialog.pick(&h.app.session, solvecraft_engine::Sel::Vertex { body: "Target".into(), point: solvecraft_engine::geom::Vec3::new(0.0, 2.0, 15.0) }); + h.app.dialog = Some(dialog); + h.frames(8); + assert!(h.widget("To surface").is_some()); + assert!(crate::scenario::handle_at("revolve").is_none()); + assert!(crate::scenario::handle_at("revolve2").is_none()); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"180 deg"})).unwrap(); + let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") }; + assert_eq!(entered, "90 deg"); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(angle(&h), "90 deg"); +} + +#[test] +fn free_drag_and_invalid_expression_stay_editable_before_commit() { + let mut h = fixture(Harness::new(), json!({})); + open(&mut h); + h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(37.3)}],"ctrl":true})).unwrap(); + let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") }; + assert!((h.app.session.doc.eval(entered, solvecraft_engine::doc::expr::Kind::Angle).unwrap().to_degrees() - 37.3).abs() < 0.2); + h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"unknown_angle"})).unwrap(); + h.frames(4); + assert!(h.widget("unknown_angle").is_some()); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_some()); + h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"60 deg"})).unwrap(); + let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") }; + assert_eq!(entered, "60 deg"); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|dialog| &dialog.error)); + assert_eq!(angle(&h), "60 deg"); + assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03); +} + +#[test] +fn second_side_can_be_dragged_to_zero_without_becoming_full_turn() { + let mut h = fixture(Harness::new(), json!({"angle":"60 deg","angle2":"30 deg"})); + open(&mut h); + h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at_angle(0.0)}]})).unwrap(); + assert_eq!(h.app.dialog.as_ref().unwrap().extra.get("angle2"), Some(&json!("0 deg"))); + h.step(&json!({"widget":"OK"})).unwrap(); + let FeatureKind::Revolve { angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(angle2.as_deref(), Some("0 deg")); + assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03); +} + +#[test] +fn edge_on_ring_drag_uses_nearest_projected_branch() { + let mut h = fixture(Harness::new(), json!({})); + open(&mut h); + h.step(&json!({"call":"ui.view","params":{"view":"top"}})).unwrap(); + handle_at(&mut h, "revolve", at_angle(90.0)); + h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(60.0)}]})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(angle(&h), "60 deg"); + assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03); +} + +#[test] +fn legacy_reversed_axis_symmetric_interval_is_shown_and_preserved_exactly() { + let mut h = fixture( + Harness::new(), + json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"direction":"symmetric","angle":"120 deg","convention":"legacy_axis_start"}), + ); + // Exercise an actual older design: no convention marker was stored by version 10. + let mut saved: Value = serde_json::from_str(&h.app.session.doc.to_json()).unwrap(); + saved["format"] = json!("solvecraft/10"); + for feature in saved["features"].as_array_mut().unwrap() { + feature.as_object_mut().unwrap().remove("convention"); + } + let path = h.dir.join("legacy-revolve.solvecraft"); + std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::LegacyAxisStart); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + assert!(before.min.x < -14.9 && before.max.x < 7.6 && before.min.z > -1e-6 && before.max.z > 14.9); + open(&mut h); + handle_at(&mut h, "revolve", at_angle(180.0)); + handle_at(&mut h, "revolve2", at_angle(60.0)); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before); + open(&mut h); + h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(135.0)}]})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(angle(&h), "90 deg"); + let after = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + assert!((after.min.x + 15.0 / 2.0f64.sqrt()).abs() < 0.03 && (after.max.x - 15.0 / 2.0f64.sqrt()).abs() < 0.03); + assert!(after.min.z > 7.0 && after.max.z > 14.9); + assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1); +} + +#[test] +fn canonical_symmetric_ring_preview_drag_and_reopen_match_material() { + for axis in [json!("Y"), json!({"origin":[0,0,0],"dir":[0,-2,0]})] { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app + .run("solid.revolve", json!({"sketch":"Profile","axis":axis,"angle":"90 deg","direction":"symmetric","name":"Turn","body_name":"Main"})) + .unwrap(); + let before = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone(); + open(&mut h); + let at = |degrees: f64| { + let a = degrees.to_radians(); + json!([3.0 * a.cos(), 5.0, 3.0 * a.sin()]) + }; + handle_at(&mut h, "revolve", at(45.0)); + handle_at(&mut h, "revolve2", at(-45.0)); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview_world(&commands).unwrap(); + let body = &preview.after.body("Main").unwrap().body; + assert!(body.contains(solvecraft_engine::geom::Vec3::new(3.0, 5.0, 0.0))); + assert!(!body.contains(solvecraft_engine::geom::Vec3::new(0.0, 5.0, 3.0))); + let bounds = preview.after.body("Main").unwrap().mesh().bounds(); + assert!((bounds.min.x - 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.x - 4.0).abs() < 0.02, "{bounds:?}"); + assert!((bounds.min.z + 2.0 * 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.z - 2.0 * 2.0f64.sqrt()).abs() < 0.02); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), before); + open(&mut h); + h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at(-30.0)}]})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(angle(&h), "60 deg"); + assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names); + assert!((volume(&h) - 60.0 * std::f64::consts::FRAC_PI_3).abs() < 0.03); + let path = h.dir.join("canonical-revolve.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + open(&mut h); + handle_at(&mut h, "revolve", at(30.0)); + handle_at(&mut h, "revolve2", at(-30.0)); + let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") }; + assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::SketchNormal); + h.step(&json!({"widget":"Cancel"})).unwrap(); + } +} + +#[test] +fn reversed_axis_to_object_canvas_sweep_and_preview_follow_the_quarter_target() { + let mut h = fixture(Harness::new(), json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"to":[0,2,12],"angle":"45 deg"})); + let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(); + let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone(); + let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + let p = 12.5 / 2.0f64.sqrt(); + open(&mut h); + assert!(h.widget("Sweep 90.0°").is_some()); + assert!(crate::scenario::handle_at("revolve").is_none()); + assert!(crate::scenario::handle_at("revolve2").is_none()); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + let preview = h.app.session.preview_world(&commands).unwrap(); + let result = preview.after.body("Main").unwrap(); + assert_eq!(result.mesh().bounds(), before); + assert!(result.body.contains(solvecraft_engine::geom::Vec3::new(p, 2.5, p))); + for (x, z) in [(-p, p), (-p, -p), (p, -p)] { + assert!(!result.body.contains(solvecraft_engine::geom::Vec3::new(x, 2.5, z))); + } + assert!((kernel::measure(&result.body).unwrap().volume - expected_volume(90.0)).abs() < 0.03); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names); + let path = h.dir.join("reversed-to-object.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + open(&mut h); + assert!(h.widget("Sweep 90.0°").is_some()); + h.step(&json!({"widget":"OK"})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before); +} diff --git a/crates/ui-egui/src/sketch3d_frame_ui_tests.rs b/crates/ui-egui/src/sketch3d_frame_ui_tests.rs new file mode 100644 index 00000000..d1a9cb10 --- /dev/null +++ b/crates/ui-egui/src/sketch3d_frame_ui_tests.rs @@ -0,0 +1,67 @@ +//! Real 3D drawing and triad input in a rotated second component occurrence. + +use serde_json::json; +use solvecraft_engine::geom::Vec3; + +#[test] +fn second_occurrence_drawn_wire_fit_picks_and_world_triad_drag_stay_aligned() { + let mut h = crate::scenario::Harness::new(); + let component = h.app.run("component.create", json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap(); + let first = h.app.session.doc.occurrence_of(component).unwrap().id; + let second = h.app.run("occurrence.copy", json!({"component":component})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("occurrence.move", json!({"occurrence":second,"translate":[20,30,40],"axis":[0,0,1],"angle":"90 deg","capture":true})).unwrap(); + h.app.run("component.activate", json!({"component":component,"occurrence":second})).unwrap(); + h.app.run("solid.box", json!({"corner":[17,32,45],"length":2,"width":2,"height":10,"body_name":"SnapVertices"})).unwrap(); + h.app.run("browser.visibility", json!({"paths":[[first]],"visible":false})).unwrap(); + let sketch = h.app.run("sketch.create", json!({"plane":"XY","name":"Wire"})).unwrap()["sketch"].as_u64().unwrap(); + h.app.ui.show_origin = false; + h.settle(); + h.step(&json!({"widget":"3D Sketch"})).unwrap(); + assert!( + crate::sketch3d::active(&h.app), + "palette click must enable 3D: raw={:?}, world={:?}, status={:?}, active={:?}", + h.app.session.model.state().sketch(sketch).unwrap().sketch.view, + h.app.session.world_state().sketch(sketch).unwrap().sketch.view, + h.app.status, + h.app.session.active_sketch + ); + h.call("ui.view", json!({"view":"home"})); + h.call("ui.view", json!({"view":"fit"})); + h.app.start("sketch.line"); + h.frames(2); + for world in [Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)] { + let [x, y] = h.at(&json!({"world":[world.x,world.y,world.z]})).unwrap(); + let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap()); + let picked = crate::sketch3d::point_at(&h.app, &proj, egui::pos2(x as f32, y as f32)); + assert!(picked.is_some_and(|(p, _)| p.dist(world) < 1e-5), "world={world:?}, picked={picked:?}, status={:?}", h.app.status); + } + h.step(&json!({"click":{"world":[17,32,45]}})).unwrap(); + h.step(&json!({"click":{"world":[17,32,55]}})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.tool.is_none()); + let raw = h.app.session.model.state(); + let wire = raw.sketch(sketch).unwrap().sketch.wires.first().expect("actual off-plane clicks create a wire"); + assert!(wire.fit[0].dist(Vec3::new(2.0, 3.0, 5.0)) < 1e-6, "{:?}", wire.fit); + assert!(wire.fit[1].dist(Vec3::new(2.0, 3.0, 15.0)) < 1e-6, "{:?}", wire.fit); + let world = h.app.session.world_state(); + let shown = &world.sketch(sketch).unwrap().sketch.wires[0]; + assert!(shown.fit[0].dist(Vec3::new(17.0, 32.0, 45.0)) < 1e-6, "{:?}", shown.fit); + assert!(shown.fit[1].dist(Vec3::new(17.0, 32.0, 55.0)) < 1e-6, "{:?}", shown.fit); + h.step(&json!({"click":{"world":[17,32,45]}})).unwrap(); + let [x0, y0] = h.at(&json!({"handle":"triad_x"})).unwrap(); + let before = h.at(&json!({"world":[17,32,45]})).unwrap(); + let after = h.at(&json!({"world":[21,32,45]})).unwrap(); + let history = h.app.session.undo.len(); + h.call("ui.drag", json!({"x0":x0,"y0":y0,"x1":x0+after[0]-before[0],"y1":y0+after[1]-before[1],"steps":8})); + let moved = h.app.session.model.state(); + let fit = &moved.sketch(sketch).unwrap().sketch.wires[0].fit; + assert!(fit[0].dist(Vec3::new(2.0, -1.0, 5.0)) < 1e-5, "{fit:?}"); + assert!(fit[1].dist(Vec3::new(2.0, 3.0, 15.0)) < 1e-6); + let placed = h.app.session.world_state(); + assert!(placed.sketch(sketch).unwrap().sketch.wires[0].fit[0].dist(Vec3::new(21.0, 32.0, 45.0)) < 1e-5); + assert_eq!(h.app.session.undo.len(), history + 1); + h.app.run("edit.undo", json!({})).unwrap(); + let restored = h.app.session.model.state(); + assert!(restored.sketch(sketch).unwrap().sketch.wires[0].fit[0].dist(Vec3::new(2.0, 3.0, 5.0)) < 1e-6); +} diff --git a/crates/ui-egui/src/sketch_attachment_ui.rs b/crates/ui-egui/src/sketch_attachment_ui.rs new file mode 100644 index 00000000..f06e2482 --- /dev/null +++ b/crates/ui-egui/src/sketch_attachment_ui.rs @@ -0,0 +1,269 @@ +//! Reviewable sketch-plane rebinding. Saved lost planes are repair guides, never model geometry. + +use egui::{Pos2, RichText}; +use serde_json::{Value, json}; +use solvecraft_engine::Sel; +use solvecraft_engine::doc::face_anchor::plane_face_anchor; +use solvecraft_engine::doc::{EmbossFace, FaceAttachmentStatus, FeatureKind, mat_inverse}; +use solvecraft_engine::geom::{Plane, Vec3}; + +use crate::SolveApp; +use crate::theme::Tokens; + +#[derive(Clone, Debug)] +pub struct Draft { + pub sketch: u64, + pub candidate: Option, + pub label: String, + pub error: Option, + pub author_path: Option>, +} + +impl Draft { + pub fn new(app: &SolveApp, sketch: u64) -> Self { + let author_path = app.session.doc.feature(sketch).and_then(|feature| match &feature.kind { + FeatureKind::Sketch { plane, .. } if plane_face_anchor(plane).is_some() => { + plane_face_anchor(plane).map(|anchor| anchor.author_path.clone()) + } + _ if feature.component == app.session.active_component => app.session.active_occurrence_path.clone(), + _ => None, + }); + Self { sketch, candidate: None, label: String::new(), error: None, author_path } + } + + fn choose(&mut self, plane: Value, label: String) { + self.candidate = Some(plane); + self.label = label; + self.error = None; + } + + pub fn commands(&self) -> Option> { + let mut params = json!({"sketch":self.sketch,"plane":self.candidate.as_ref()?}); + if let Some(path) = &self.author_path { + params["author_path"] = json!(path); + } + Some(vec![("sketch.redefine".into(), params)]) + } + + pub fn choose_saved_fixed(&mut self, app: &SolveApp, world: Plane) -> Result<(), String> { + let doc = app.session.placed_document(); + let owner = doc.feature(self.sketch).ok_or("Sketch no longer exists")?.component; + let path = self.author_path.clone().unwrap_or_else(|| doc.first_path(owner)); + if self.author_path.is_none() + && path.iter().any(|id| { + doc.occurrences + .iter() + .find(|o| o.id == *id) + .is_none_or(|selected| doc.occurrences.iter().filter(|o| o.component == selected.component).count() != 1) + }) + { + return Err("Select an exact author placement before converting this plane".into()); + } + let frame = EmbossFace::component_frame(&doc, owner, &path).map_err(|error| error.to_string())?; + mat_inverse(&frame).ok_or("Sketch author frame is invalid")?; + self.author_path = Some(path); + // Public commands take world geometry. Session maps Custom planes into + // this exact author placement once, including redefine's saved path. + self.choose(fixed_plane(world), "Fixed plane: independent of the source body".into()); + Ok(()) + } +} + +pub fn clear(app: &mut SolveApp) { + app.tree.redefine = None; + app.tree.redefine_draft = None; +} + +pub fn commands(app: &SolveApp) -> Option> { + let draft = app.tree.redefine_draft.as_ref()?; + (app.tree.redefine == Some(draft.sketch)).then(|| draft.commands()).flatten() +} + +fn attachment(app: &SolveApp, sketch: u64) -> FaceAttachmentStatus { + app.session.placed_document().sketch_face_attachment(sketch, &app.session.model) +} + +fn fixed_plane(plane: Plane) -> Value { + json!({"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y}) +} + +pub fn face_plane(app: &SolveApp, selection: &Sel) -> Option { + match selection { + Sel::Face { body, index, point } => { + let doc = app.session.placed_document(); + let (_, _, _, path, _) = doc.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body)?; + Some(json!({"face":point,"body":body,"index":index,"path":path})) + } + _ => None, + } +} + +fn face_label(app: &SolveApp, selection: &Sel) -> String { + let Sel::Face { body, .. } = selection else { return String::new() }; + let placed = app.session.placed_document(); + if let Some((_, source, _, path, _)) = + placed.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body) + { + format!("Face on {source} / {}", path_label(app, &path)) + } else { + format!("Face on {body}") + } +} + +fn path_label(app: &SolveApp, path: &[u64]) -> String { + if path.is_empty() { + return "Root".into(); + } + (1..=path.len()) + .map(|end| app.session.occurrence_placement(&path[..end]).map(|placement| placement.name).unwrap_or_else(|_| "Missing occurrence".into())) + .collect::>() + .join(" / ") +} + +pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) { + let Some(sketch) = app.tree.redefine else { return }; + if app.session.doc.feature(sketch).is_none() { + clear(app); + return; + } + let mut draft = app.tree.redefine_draft.take().filter(|draft| draft.sketch == sketch).unwrap_or_else(|| Draft::new(app, sketch)); + let status = attachment(app, sketch); + let name = app.session.doc.feature(sketch).map(|feature| feature.name.as_str()).unwrap_or("Sketch"); + let selected = app.session.selection.iter().find(|selection| matches!(selection, Sel::Face { .. })).cloned(); + let mut open = true; + let mut apply = false; + let mut cancel = false; + let t = Tokens::get(); + crate::frame::docked( + format!("Redefine Sketch Plane: {name}"), + egui::Id::new("sc_redefine"), + at, + app.viewport.rect.map(|rect| rect.bottom()).unwrap_or_else(|| ctx.content_rect().bottom()) - at.y - 8.0, + crate::frame::Width::Normal, + ) + .open(&mut open) + .scroll([false, true]) + .show(ctx, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + ui.strong(format!("Redefine Sketch Plane: {name}")); + ui.separator(); + let saved_plane = match &status { + FaceAttachmentStatus::Healthy { plane, target, path } => { + ui.label(format!("Attached to {} / {}", target.body, path_label(app, path))); + Some(*plane) + } + FaceAttachmentStatus::Lost { message, repair_plane } => { + ui.label(RichText::new("Face attachment lost").color(t.error)); + ui.label(message); + if repair_plane.is_some() { + ui.label("The dashed sketch is a saved plane guide. Select a new face to repair the attachment."); + } else { + ui.label("The saved author occurrence is missing. Select a valid author placement before rebinding."); + } + *repair_plane + } + FaceAttachmentStatus::Legacy { plane } => { + ui.label("Legacy face attachment. Rebinding to a certified face creates an exact attachment."); + *plane + } + FaceAttachmentStatus::Fixed { plane } => { + ui.label("Sketch plane has no body-face attachment."); + *plane + } + }; + ui.separator(); + if matches!(&status, FaceAttachmentStatus::Lost { repair_plane: None, .. }) { + let author = app.session.doc.feature(sketch).map(|feature| feature.component); + let available = author == Some(app.session.active_component); + if ui.add_enabled(available, egui::Button::new("Use active author placement")).clicked() { + draft.author_path = Some(app.session.active_occurrence_path.clone().unwrap_or_default()); + draft.error = None; + } + if available && let Some(path) = &draft.author_path { + ui.label(format!("Author placement: {}", path_label(app, path))); + } + } + ui.label("Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates."); + ui.horizontal_wrapped(|ui| { + for plane in ["XY", "XZ", "YZ"] { + if ui.button(format!("Use {plane} plane")).clicked() { + draft.choose(json!(plane), plane.into()); + } + } + }); + for feature in &app.session.doc.features { + if matches!(feature.kind, FeatureKind::ConstructionPlane { .. }) && ui.button(&feature.name).clicked() { + draft.choose(json!(feature.name), feature.name.clone()); + } + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Reattach selected face")).clicked() + && let Some(selection @ Sel::Face { .. }) = &selected + && let Some(plane) = face_plane(app, selection) + { + let label = face_label(app, selection); + draft.choose(plane, label); + } + if ui.add_enabled(saved_plane.is_some(), egui::Button::new("Use saved fixed plane")).clicked() + && let Some(plane) = saved_plane + && let Err(error) = draft.choose_saved_fixed(app, plane) + { + draft.error = Some(error); + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Use selected face as fixed plane")).clicked() + && let Some(selection) = &selected + && let Some(mut plane) = face_plane(app, selection) + { + plane["fixed"] = json!(true); + draft.choose(plane, "Fixed plane: independent of the selected body".into()); + } + if draft.candidate.is_some() { + ui.separator(); + ui.label(format!("Preview: {}", draft.label)); + } + if let Some(error) = draft.error.as_ref().or(app.preview.error.as_ref()) { + ui.label(RichText::new(error).color(t.error)); + } + ui.horizontal(|ui| { + if ui.add_enabled(draft.candidate.is_some() && app.preview.error.is_none(), egui::Button::new("Apply")).clicked() { + apply = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { + cancel = true; + app.esc_handled = true; + } + if apply && let Some(commands) = draft.commands() { + for (command, params) in commands { + match app.run(&command, params) { + Ok(_) => cancel = true, + Err(error) => draft.error = Some(error), + } + } + } + if cancel || !open { + clear(app); + } else { + app.tree.redefine_draft = Some(draft); + } +} + +/// Only a lost reference's explicit saved author frame supplies a guide. It is +/// absent from ModelState, selection, snapping and all downstream evaluation. +pub fn lost_guide(app: &SolveApp) -> Option<(Plane, &solvecraft_engine::sketch::Sketch)> { + let sketch = app.tree.redefine?; + let FaceAttachmentStatus::Lost { repair_plane: Some(plane), .. } = attachment(app, sketch) else { return None }; + let FeatureKind::Sketch { sketch, .. } = &app.session.doc.feature(sketch)?.kind else { return None }; + Some((plane, sketch)) +} + +pub fn guide_lines(app: &SolveApp) -> Vec> { + let Some((plane, sketch)) = lost_guide(app) else { return Vec::new() }; + (0..sketch.curves.len()) + .flat_map(|index| sketch.segs(index)) + .map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect()) + .collect() +} diff --git a/crates/ui-egui/src/sketch_attachment_ui_tests.rs b/crates/ui-egui/src/sketch_attachment_ui_tests.rs new file mode 100644 index 00000000..88cbe936 --- /dev/null +++ b/crates/ui-egui/src/sketch_attachment_ui_tests.rs @@ -0,0 +1,324 @@ +use crate::scenario::Harness; +use serde_json::{Value, json}; +use solvecraft_engine::doc::{FaceAttachmentStatus, FeatureKind, PlaneRef}; + +fn picked_sketch() -> (Harness, u64) { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.session.auto_project = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BaseBox","name":"Original"})).unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,20],"body_name":"ControlBox","name":"Control"})).unwrap(); + h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.step(&json!({"click":{"world":[3,4,10]}})).unwrap(); + h.settle(); + let sketch = h.app.session.active_sketch.expect("actual picked face creates the sketch"); + let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else { + panic!("picked face must create a certified attachment"); + }; + assert_eq!(anchor.target.body, "BaseBox"); + assert!(anchor.path.is_empty()); + h.app.run("parameters.add", json!({"name":"badge_diameter","expression":"2 mm"})).unwrap(); + h.step(&json!({"start":"sketch.circle.center"})).unwrap(); + h.step(&json!({"click":{"world":[5,5,10]}})).unwrap(); + h.step(&json!({"move":{"world":[7,5,10]}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"badge_diameter"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + h.app.finish_sketch(); + h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap(); + assert!(!h.app.session.doc.sketch(sketch).unwrap().constraints.is_empty()); + (h, sketch) +} + +fn raw_sketch(h: &Harness, id: u64) -> Value { + serde_json::to_value(h.app.session.doc.sketch(id).unwrap()).unwrap() +} + +#[test] +fn actual_picked_face_and_noop_edit_preserve_exact_anchor_and_dimensions() { + let (mut h, sketch) = picked_sketch(); + let before = serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(); + h.app.edit_sketch(sketch); + h.settle(); + h.app.finish_sketch(); + assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before); + let path = h.dir.join("picked-face.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before); + assert!(matches!(h.app.session.doc.sketch_face_attachment(sketch, &h.app.session.model), FaceAttachmentStatus::Healthy { .. })); +} + +#[test] +fn lost_attachment_ghost_rebind_preview_cancel_apply_and_undo_preserve_sketch() { + let (mut h, sketch) = picked_sketch(); + h.app.run("solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new_body","body_name":"Boss"})).unwrap(); + h.app.run("timeline.suppress", json!({"feature":"Original","suppressed":true})).unwrap(); + assert!(h.app.session.model.result(sketch).unwrap().error.is_some()); + assert!(h.app.session.world_state().body("Boss").is_none(), "lost attachment cannot produce a Boss on ControlBox"); + assert!(h.app.session.world_state().sketch(sketch).is_none(), "saved repair guides are not solved sketch geometry"); + let raw = raw_sketch(&h, sketch); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let redo = h.app.session.redo.len(); + h.app.edit_sketch(sketch); + h.frames(4); + assert_eq!(h.app.tree.redefine, Some(sketch)); + assert!(h.widget("Face attachment lost").is_some()); + let guides = crate::sketch_attachment_ui::guide_lines(&h.app); + assert!(!guides.is_empty()); + assert!(guides.iter().flatten().all(|point| (point.z - 10.0).abs() < 1e-8)); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap(); + h.app.session.selection.clear(); + h.frames(4); + h.step(&json!({"click":{"world":[5,5,10]}})).unwrap(); + assert!(h.app.session.selection.is_empty(), "saved lost-plane guides must not be selectable geometry"); + assert!(h.app.session.active_sketch.is_none()); + h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap(); + h.frames(4); + h.step(&json!({"click":{"world":[3,4,30]}})).unwrap(); + assert!(matches!(h.app.session.selection.first(), Some(solvecraft_engine::Sel::Face { body, .. }) if body=="ControlBox")); + h.frames(2); + h.step(&json!({"widget":"Reattach selected face"})).unwrap(); + let commands = crate::sketch_attachment_ui::commands(&h.app) + .unwrap_or_else(|| panic!("repair draft {:?}, selection {:?}", h.app.tree.redefine_draft, h.app.session.selection)); + assert_eq!(commands[0].1["plane"]["body"], "ControlBox"); + assert!(commands[0].1["plane"]["index"].as_u64().is_some()); + let preview = h.app.session.preview_visible_world(&commands).unwrap(); + let boss = preview.after.body("Boss").unwrap().mesh().bounds(); + assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7); + assert_eq!(h.app.session.doc.to_json(), before, "preview is scratch only"); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.redo.len(), redo); + assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty()); + + crate::browser::redefine_sketch(&mut h.app, sketch); + h.frames(4); + h.step(&json!({"widget":"Reattach selected face"})).unwrap(); + h.frames(8); + h.step(&json!({"widget":"Apply"})).unwrap(); + assert!(h.app.tree.redefine.is_none(), "{:?}", h.app.tree.redefine_draft); + assert_eq!(raw_sketch(&h, sketch), raw, "curves, dimensions and expressions remain exact"); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.session.model.result(sketch).unwrap().error.is_none()); + let boss = h.app.session.world_state().body("Boss").unwrap().mesh().bounds(); + assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7); + let repaired = h.app.session.doc.to_json(); + let path = h.dir.join("rebound-face.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + h.app.run("doc.open", json!({"path":path})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), repaired); + assert_eq!(raw_sketch(&h, sketch), raw); + assert!(h.app.session.model.result(sketch).unwrap().error.is_none()); +} + +#[test] +fn attachment_drafts_leave_on_document_replacement_but_survive_failed_open() { + let (mut h, sketch) = picked_sketch(); + crate::browser::redefine_sketch(&mut h.app, sketch); + h.frames(2); + h.step(&json!({"widget":"Use XZ plane"})).unwrap(); + assert!(crate::sketch_attachment_ui::commands(&h.app).is_some()); + assert!(h.app.run("doc.open", json!({"path":h.dir.join("missing.solvecraft")})).is_err()); + assert!(crate::sketch_attachment_ui::commands(&h.app).is_some()); + h.app.run("file.new", json!({})).unwrap(); + assert!(h.app.tree.redefine.is_none()); + assert!(h.app.tree.redefine_draft.is_none()); +} + +#[test] +fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap(); + h.app.run("solid.fillet", json!({"edges":[[0,0,5]],"radius":1})).unwrap(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.step(&json!({"click":{"world":[5,5,10]}})).unwrap(); + assert!(h.app.session.active_sketch.is_none()); + assert!(h.app.dialog.as_ref().unwrap().error.is_some()); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + h.frames(4); + assert!(h.widget("Use fixed plane").is_some()); + h.step(&json!({"widget":"Use fixed plane"})).unwrap(); + let sketch = h.app.session.active_sketch.expect("explicit fixed choice succeeds for the actual planar surface"); + let FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: geometry } = &h.app.session.doc.feature(sketch).unwrap().kind else { + panic!("fixed choice must never create a durable-looking Face reference"); + }; + assert!((plane.origin.z - 10.0).abs() < 1e-7); + assert!(geometry.links.is_empty(), "a fixed snapshot cannot carry live source projections"); + assert_eq!(h.app.session.undo.len(), undo + 1); +} + +fn occurrence_click(h: &mut Harness, path: &[u64], control: &str) { + h.step(&json!({"click":{"occurrence":path,"control":control}})).unwrap(); +} + +#[test] +fn actual_nested_second_placement_face_pick_keeps_target_and_author_paths() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.session.auto_project = false; + let parent = h.app.run("component.create", json!({"name":"Parent"})).unwrap(); + let parent_component = parent["component"].as_u64().unwrap(); + let first_parent = parent["occurrence"].as_u64().unwrap(); + let child = h.app.run("component.create", json!({"name":"Child","parent":parent_component,"parent_path":[first_parent]})).unwrap(); + let child_component = child["component"].as_u64().unwrap(); + let child_occurrence = child["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"NestedBase"})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + let second_parent = + h.app.run("occurrence.copy", json!({"occurrence":first_parent,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.frames(4); + let path = vec![second_parent, child_occurrence]; + if !crate::browser::drawn_occurrences().iter().any(|(drawn, _, _, _)| drawn == &path) { + occurrence_click(&mut h, &[second_parent], "fold"); + } + occurrence_click(&mut h, &path, "activate"); + assert_eq!(h.app.session.active_component, child_component); + assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path)); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.step(&json!({"click":{"world":[43,4,10]}})).unwrap(); + let sketch = h.app.session.active_sketch.expect("actual second nested face pick"); + let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone(); + let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &kind else { + panic!("strict face attachment"); + }; + assert_eq!(anchor.target.body, "NestedBase"); + assert_eq!(anchor.path, path); + assert_eq!(anchor.author_path, path); + assert_eq!(anchor.component, child_component); + let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else { + panic!("selected saved placement resolves"); + }; + assert!((plane.origin.x - 40.0).abs() < 1e-7 && (plane.origin.z - 10.0).abs() < 1e-7); + h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap(); + h.app.run("sketch.dimension", json!({"entities":["l1"],"value":2})).unwrap(); + h.app.finish_sketch(); + let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + h.app.edit_sketch(sketch); + assert_eq!(h.app.session.active_component, child_component); + assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path)); + h.app.finish_sketch(); + assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind); + let file = h.dir.join("nested-face.solvecraft"); + h.app.run("file.save", json!({"path":file})).unwrap(); + h.app.run("doc.open", json!({"path":file})).unwrap(); + assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind); + h.app.run("selection.delete", json!({"occurrences":[second_parent]})).unwrap(); + assert!(h.app.session.model.result(sketch).unwrap().error.is_some()); + let FaceAttachmentStatus::Lost { repair_plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else { + panic!("deleted second placement must not substitute first"); + }; + assert!(repair_plane.is_none(), "a missing saved author placement cannot be drawn at its sibling"); + h.app.edit_sketch(sketch); + h.frames(4); + assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty()); + assert_eq!(h.app.tree.redefine, Some(sketch)); +} + +#[test] +fn nested_anchor_saved_fixed_conversion_preserves_rotated_second_author_plane() { + use solvecraft_engine::doc::{EmbossFace, apply_point, apply_vector}; + use solvecraft_engine::geom::Plane; + use solvecraft_engine::geom::Vec3; + let mut h = Harness::new(); + h.app.session.auto_project = false; + let created = h.app.run("component.create", json!({"name":"Author"})).unwrap(); + let component = created["component"].as_u64().unwrap(); + let first = created["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})).unwrap(); + let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,1,0],"angle":"90 deg","capture":true})).unwrap(); + h.app.run("component.activate", json!({"component":component,"path":[second],"occurrence":second})).unwrap(); + let doc = h.app.session.placed_document(); + let body_name = doc.body_frames_with_source(&h.app.session.model.state()).into_iter().find(|(_, _, _, path, _)| path == &[second]).unwrap().0; + let world = h.app.session.world_state(); + let face = world + .body(&body_name) + .unwrap() + .body + .faces(0.01) + .unwrap() + .into_iter() + .find(|face| world.body(&body_name).unwrap().body.face_plane(face.index).is_some_and(|plane| plane.normal().dot(Vec3::X) > 0.999)) + .unwrap(); + let id = h + .app + .run("sketch.create", json!({"plane":{"body":body_name,"index":face.index,"face":face.centroid,"path":[second]},"project_edges":false})) + .unwrap()["sketch"] + .as_u64() + .unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap(); + h.app.finish_sketch(); + // Exercise a durable anchor under both public plane wrappers, as imported + // documents can contain these without an interactive wrapper editor. + let mut document = (*h.app.session.doc).clone(); + let FeatureKind::Sketch { plane, .. } = &mut document.feature_mut(id).unwrap().kind else { + panic!("sketch"); + }; + *plane = PlaneRef::AtAngle { + base: Box::new(PlaneRef::Offset { base: Box::new(plane.clone()), distance: "2 mm".into() }), + axis_origin: Vec3::ZERO, + axis_dir: Vec3::X, + angle: "15 deg".into(), + }; + h.app.session = solvecraft_engine::Session::new(document); + let raw = h.app.session.doc.sketch(id).unwrap().clone(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let FaceAttachmentStatus::Healthy { plane: expected, .. } = h.app.session.placed_document().sketch_face_attachment(id, &h.app.session.model) + else { + panic!("wrapped anchor must resolve"); + }; + crate::browser::redefine_sketch(&mut h.app, id); + h.frames(4); + assert_eq!(h.app.tree.redefine_draft.as_ref().unwrap().author_path, Some(vec![second])); + h.step(&json!({"widget":"Use saved fixed plane"})).unwrap(); + let commands = crate::sketch_attachment_ui::commands(&h.app).unwrap(); + let preview = h.app.session.preview_visible_world(&commands).unwrap(); + // The shared sketch's primary world entry belongs to the untransformed + // first placement. Verify that local plane at the specifically chosen + // second placement, rather than treating the primary entry as that path. + let frame = EmbossFace::component_frame(&h.app.session.placed_document(), component, &[second]).unwrap(); + let selected_plane = + |local: Plane| Plane::new(apply_point(&frame, local.origin), apply_vector(&frame, local.x), apply_vector(&frame, local.y)).unwrap(); + let shown = selected_plane(preview.after.sketch(id).unwrap().plane); + assert!( + shown.origin.dist(expected.origin) < 1e-7, + "expected {expected:?}, shown {shown:?}, primary {:?}, commands {commands:?}", + preview.after.sketch(id).unwrap().plane + ); + assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7); + assert_eq!(h.app.session.doc.to_json(), before); + h.frames(6); + h.step(&json!({"widget":"Apply"})).unwrap(); + assert!(h.app.tree.redefine.is_none()); + assert_eq!(h.app.session.doc.sketch(id).unwrap(), &raw); + assert_eq!(h.app.session.undo.len(), undo + 1); + let shown = selected_plane(h.app.session.model.state().sketch(id).unwrap().plane); + assert!(shown.origin.dist(expected.origin) < 1e-7); + assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); +} 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..419001ce --- /dev/null +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -0,0 +1,159 @@ +use super::*; +use solvecraft_engine::sketch::CurveKind; + +#[test] +fn clicked_periodic_fit_closes_at_start_and_right_click_keeps_natural() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.settle(); + h.step(&json!({"start":"sketch.spline.fit_point"})).unwrap(); + for point in [[2, 2, 0], [8, 2, 0], [9, 7, 0], [1, 6, 0]] { + h.step(&json!({"click":{"world":point}})).unwrap(); + } + let sid = h.app.session.active_sketch.unwrap(); + assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.is_empty()); + h.step(&json!({"move":{"world":[2,2,0]}})).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("Close smooth loop")); + h.step(&json!({"click":{"world":[2,2,0]}})).unwrap(); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.curves.len(), 1, "{:?}", h.app.status); + assert!(matches!(sk.curves[0].kind, CurveKind::Spline { periodic: true, .. })); + sk.validate_periodic_splines().unwrap(); + assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 1); + assert!(h.app.tool.as_ref().unwrap().pts.is_empty()); + for point in [[15, 2, 0], [20, 2, 0], [20, 6, 0]] { + h.step(&json!({"click":{"world":point}})).unwrap(); + } + h.step(&json!({"click":{"world":[20,6,0]},"button":"right"})).unwrap(); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.curves.len(), 2); + assert!(matches!(sk.curves[1].kind, CurveKind::Spline { periodic: false, .. })); +} + +#[test] +fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + h.step(&json!({"start":"sketch.offset"})).unwrap(); + // Major tip is also a source handle: Offset must choose the actual curve. + h.step(&json!({"click":{"world":[10,0,0]}})).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().picks, vec!["e1"]); + h.step(&json!({"click":{"world":[11,0,0]}})).unwrap(); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.freeform_offsets.len(), 1, "{:?}", h.app.status); + assert!((sk.freeform_offsets[0].distance + 1.0).abs() < 1e-4); + let first = sk.freeform_offsets[0].output.clone(); + let saved = h.app.session.doc.to_json(); + h.step(&json!({"click":{"world":[11,0,0]}})).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().picks, vec![first]); + h.step(&json!({"click":{"world":[11.5,0,0]}})).unwrap(); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.freeform_offsets.len(), 2, "{:?}", h.app.status); + assert!((sk.freeform_offsets[1].distance + 0.5).abs() < 1e-4); + assert!(sk.freeform_offsets.iter().all(|r| r.certificate == 2)); + assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 3); + let last = sk.curve_index(&sk.freeform_offsets[1].output).unwrap(); + assert!(sk.exact_segs(last).unwrap()[0].point_at(0.0).dist(Vec2::new(11.5, 0.0)) < 1e-4); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), saved); +} + +#[test] +fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() { + 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.ellipse", json!({"center":[20,0],"major":[30,0],"minor_radius":5})).unwrap(); + h.app.run("sketch.constraint.fix", json!({"entity":"e1"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,3],[5,3]],"connect":false,"infer":false})).unwrap(); + h.frames(2); + h.step(&json!({"start":"sketch.extend"})).unwrap(); + h.step(&json!({"click":{"world":[5,3,0]}})).unwrap(); + let st = h.app.session.world_state(); + let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let end = ss.sketch.point(ss.sketch.resolve_point("l1.end").unwrap()).unwrap(); + assert!(end.dist(Vec2::new(12.0, 3.0)) < 1e-7, "clicked endpoint must extend: {end:?}, {:?}", h.app.status); + assert_eq!(ss.sketch.point(ss.sketch.resolve_point("l1.start").unwrap()), Some(Vec2::new(0.0, 3.0))); +} + +#[test] +fn derived_offset_context_action_releases_geometry_and_undo_restores() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap(); + let out = h.app.run("sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap(); + let id = out["curves"][0].as_str().unwrap().to_string(); + h.app.session.selection = vec![solvecraft_engine::Sel::SketchCurve { id: id.clone() }]; + let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport); + let item = items.iter().find(|item| item.id == "sketch.offset.break").unwrap(); + assert_eq!(item.params, Some(json!({"curve":id}))); + crate::context_menu::run_item(&mut h.app, item, egui::Pos2::ZERO); + let sid = h.app.session.active_sketch.unwrap(); + assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.is_empty()); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.len(), 1); +} + +#[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..38c3bf39 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -50,7 +50,7 @@ pub fn boxes_for(cmd: &str, n: usize) -> Vec { /// A locked box's value (mm or radians). fn locked(app: &SolveApp, t: &Tool, label: &str) -> Option { - let b = t.dims.iter().find(|b| b.label == label && b.locked)?; + let b = t.dims.iter().chain(&t.dim_history).find(|b| b.label == label && b.locked)?; app.session.doc.eval(&b.value, b.kind).ok().filter(|v| v.is_finite()) } @@ -181,29 +181,71 @@ fn fmt(kind: ValueKind, v: f64) -> String { /// Make the tool's boxes match its stage (new boxes when a point was placed). pub fn sync(t: &mut Tool) { if t.dims_stage != t.pts.len() { + if t.dims_stage == 1 + && t.pts.len() == 2 + && matches!(t.cmd, "sketch.arc.center_point" | "sketch.slot.center_to_center" | "sketch.slot.overall") + { + t.dim_history.extend(t.dims.iter().filter(|dimension| dimension.locked).cloned()); + } else if t.pts.len() <= t.dims_stage || t.cmd == "sketch.line" { + t.dim_history.clear(); + } t.dims = boxes_for(t.cmd, t.pts.len()); t.dims_stage = t.pts.len(); t.dims_focus = !t.dims.is_empty(); } } -/// The dimension constraints the locked values ask for after the shape made `curves`: -/// (entities, type, value). -pub fn constraints(t: &Tool, curves: &[String]) -> Vec<(Vec, &'static str, String)> { - let mut out = Vec::new(); - for b in t.dims.iter().filter(|b| b.locked) { - let target = match (t.cmd, b.label) { - ("sketch.line", "Length") | ("sketch.rectangle.two_point" | "sketch.rectangle.center", "Width") => curves.first().map(|c| (c, "length")), - ("sketch.rectangle.two_point" | "sketch.rectangle.center", "Height") => curves.get(1).map(|c| (c, "length")), - ("sketch.circle.center", "Diameter") => curves.first().map(|c| (c, "diameter")), - ("sketch.arc.center_point", "Radius") => curves.first().map(|c| (c, "radius")), - _ => None, - }; - if let Some((c, ty)) = target { - out.push((vec![c.clone()], ty, b.value.clone())); +/// Clear a shape's input immediately, including when another first point arrives before redraw. +pub fn reset(t: &mut Tool) { + t.dims.clear(); + t.dim_history.clear(); + t.dims_stage = usize::MAX; + t.dims_focus = false; +} + +pub fn has_locked(t: &Tool, label: &str) -> bool { + t.dims.iter().chain(&t.dim_history).any(|dimension| dimension.locked && dimension.label == label) +} + +/// Original expressions, rather than sampled numbers, become the shape's permanent dimensions. +pub fn dimensions(t: &Tool) -> Value { + let values = t + .dim_history + .iter() + .chain(&t.dims) + .filter(|dimension| dimension.locked && dimension.label != "Sides") + .map(|dimension| (dimension.label.to_ascii_lowercase(), json!(dimension.value))) + .collect::>(); + Value::Object(values) +} + +/// Invalid values stay in the current field, so users can correct them before another stage. +pub fn validate(app: &SolveApp, t: &Tool) -> Result<(), String> { + for dimension in t.dim_history.iter().chain(&t.dims).filter(|dimension| dimension.locked) { + if dimension.value.len() > 4096 { + return Err(format!("{} expression is too long", dimension.label)); + } + let value = app.session.doc.eval(&dimension.value, dimension.kind).map_err(|error| format!("{}: {error}", dimension.label))?; + if !value.is_finite() || value.abs() > 1e9 { + return Err(format!("{} must be finite and within the model's size limits", dimension.label)); + } + match dimension.label { + "Angle" => {} + "Sweep" if value.abs() > 1e-9 && value.abs() < std::f64::consts::TAU - 1e-9 => {} + "Sweep" => return Err("Sweep must lie strictly between -360 and 360 degrees, and cannot be zero".into()), + "Sides" if (3.0..=200.0).contains(&value) && value.fract() == 0.0 => {} + "Sides" => return Err("Sides must be an integer from 3 to 200".into()), + _ if value > 1e-9 => {} + _ => return Err(format!("{} must be positive", dimension.label)), } } - out + if t.cmd == "sketch.slot.overall" + && let (Some(length), Some(width)) = (locked(app, t, "Length"), locked(app, t, "Width")) + && length <= width + { + return Err("Overall slot Length must exceed Width".into()); + } + Ok(()) } /// A locked box value by label (for the polygon side count). @@ -240,6 +282,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 +326,21 @@ 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 + // The drawing tool handles Enter. Keep the text edit focused when a + // rejected value needs correction, instead of surrendering the keyboard. + .add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).return_key(None).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,12 +350,30 @@ 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) } -/// JSON for a typed dimension. -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; + +#[cfg(test)] +#[path = "sketch_typed_intent_tests.rs"] +mod typed_intent_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..c54c857c 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -373,6 +373,7 @@ pub fn tool_for(id: &str) -> Option { hover: None, picks: Vec::new(), dims: Vec::new(), + dim_history: Vec::new(), dims_stage: usize::MAX, dims_focus: false, }) @@ -402,7 +403,7 @@ pub fn hint(id: &str) -> Option { "sketch.slot.center_point" => "Slot: click the centre, an arc centre, then the width", "sketch.slot.arc_three_point" => "Arc slot: click start, a point on the arc, the end, then the width", "sketch.slot.arc_center" => "Arc slot: click the centre, the start, the end, then the width", - "sketch.spline.fit_point" => "Spline: click fit points; right-click to finish", + "sketch.spline.fit_point" => "Spline: click fit points; click the start to close smoothly, or right-click to finish", "sketch.spline.control_point" | "sketch.spline.control_point_5" => "Control point spline: click control points; right-click to finish", "sketch.text" => "Text: click where the text starts", "sketch.blend_curve" => "Blend curve: click the ends of two curves", @@ -458,6 +459,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]) } @@ -510,6 +546,29 @@ fn round_nice(x: f64) -> f64 { } /// A click with an extra tool. +pub fn periodic_start_snap(app: &SolveApp, tool: &Tool, proj: &Proj, pos: Pos2) -> Option { + if tool.cmd != "sketch.spline.fit_point" || tool.pts.len() < 3 || crate::drag_snap::suppressed() { + return None; + } + let first = tool.pts.first()?.0; + let mut distinct = Vec::new(); + for p in &tool.pts { + if !distinct.contains(&p.0) { + distinct.push(p.0); + } + if distinct.len() == 3 { + break; + } + } + if distinct.len() < 3 { + return None; + } + let state = app.session.world_state(); + let plane = &state.sketch(app.session.active_sketch?)?.plane; + let screen = proj.to_screen(plane.to_world(first))?; + (screen.distance(pos) <= 8.0).then_some(first) +} + pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { let Some(m) = mode(tool.cmd) else { return }; let hits = pick(app, proj, pos); @@ -611,11 +670,27 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } Mode::CurveAt => { - if let (Some((id, false)), Some((p, _))) = (sketch_entity(app, &hits), at) { + let curve = active_sketch(app).and_then(|active| { + hits.iter().find_map(|hit| match hit { + Hit::SketchCurve { sketch, id, .. } if *sketch == active => Some(id.clone()), + _ => None, + }) + }); + if let (Some(id), Some((p, _))) = (curve, at) { let _ = app.run(cmd, json!({"curve": id, "at": xy(p)})); } } Mode::Points(n) => { + if periodic_start_snap(app, tool, proj, pos).is_some() { + // Only this deliberate click selects periodic interpolation. A + // right-click finish and all previously saved splines stay natural. + let params = json!({"points":tool.pts.iter().map(arg).collect::>(),"periodic":true}); + if app.run(cmd, params).is_ok() { + tool.pts.clear(); + crate::sketch_dims::reset(tool); + } + return; + } let Some(p) = at else { return }; tool.pts.push(p); if n > 0 && tool.pts.len() >= n { @@ -624,28 +699,37 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } Mode::Entities(n) => { - if cmd == "sketch.offset" && !tool.picks.is_empty() { - if let Some((p, _)) = at { - offset_side(app, tool, p); - } - return; - } - let Some((id, is_point)) = sketch_entity(app, &hits) else { return }; - if cmd == "sketch.offset" { - // First the curve, then the side point. - if tool.picks.is_empty() && !is_point { - tool.picks.push(id); - } else if let Some((p, _)) = at { - offset_side(app, tool, p); - } - return; - } if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") { - if is_point { + if let Some((_, id)) = corner_point(app, proj, pos, cmd) { run_corner(app, cmd, &id); } return; } + if cmd == "sketch.offset" && !tool.picks.is_empty() { + // Dense derived fit nodes must not snap a nearby side click back + // onto the source, reducing a deliberate offset to zero. + let state = app.session.world_state(); + let side = active_sketch(app).and_then(|sid| state.sketch(sid)).and_then(|ss| { + let (o, d) = proj.ray(pos); + ss.plane.intersect_ray(o, d).map(|p| ss.plane.to_local(p)) + }); + if let Some(p) = side { + offset_side(app, tool, p); + } + return; + } + if cmd == "sketch.offset" { + if let Some(sid) = active_sketch(app) + && let Some(id) = hits.iter().find_map(|hit| match hit { + Hit::SketchCurve { sketch, id, .. } if *sketch == sid => Some(id.clone()), + _ => None, + }) + { + tool.picks.push(id); + } + return; + } + let Some((id, _)) = sketch_entity(app, &hits) else { return }; if cmd == "sketch.centerline" { let _ = app.run(cmd, json!({"curves": [id]})); return; @@ -848,8 +932,14 @@ fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool { let st = app.session.world_state(); let d = active_sketch(app) .and_then(|s| st.sketch(s)) - .and_then(|ss| ss.sketch.curve_index(&tool.picks[0]).and_then(|c| ss.sketch.shape(c))) - .map(|sh| sh.dist(p)) + .and_then(|ss| { + let curve = ss.sketch.curve_index(&tool.picks[0])?; + if let Some(shape) = ss.sketch.shape(curve) { + Some(shape.dist(p)) + } else { + ss.sketch.exact_segs(curve)?.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).min_by(f64::total_cmp) + } + }) .unwrap_or(0.0); if d > 1e-6 { let _ = app.run("sketch.offset", json!({"curves": [tool.picks[0]], "distance": d, "side": xy(p)})); @@ -1132,3 +1222,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/sketch_typed_intent_tests.rs b/crates/ui-egui/src/sketch_typed_intent_tests.rs new file mode 100644 index 00000000..646ed903 --- /dev/null +++ b/crates/ui-egui/src/sketch_typed_intent_tests.rs @@ -0,0 +1,184 @@ +//! Keyboard-authored intent must survive stage transitions and later model edits. +use crate::scenario::Harness; +use serde_json::json; +use solvecraft_engine::sketch::{ConstraintKind, CurveKind}; + +fn start(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,0,0]}})).unwrap(); + h +} +fn type_value(h: &mut Harness, expression: &str) { + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":expression})).unwrap(); +} +fn enter(h: &mut Harness) { + h.step(&json!({"key":"Enter"})).unwrap(); +} +fn constraints(h: &Harness) -> Vec { + h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.constraints.iter().map(|c| c.kind.clone()).collect() +} + +#[test] +fn typed_arc_radius_and_signed_sweep_survive_stages_parameter_edit_and_reopen() { + let mut h = start("sketch.arc.center_point"); + h.app.run("parameters.add", json!({"name":"arc_radius","expression":"5 mm"})).unwrap(); + type_value(&mut h, "arc_radius"); + enter(&mut h); + let tool = h.app.tool.as_ref().unwrap(); + assert_eq!(tool.pts.len(), 2); + assert_eq!(tool.dim_history[0].value, "arc_radius"); + assert_eq!(tool.dims[0].label, "Sweep"); + type_value(&mut h, "-90 deg"); + enter(&mut h); + assert!(h.app.tool.as_ref().unwrap().dim_history.is_empty()); + let kinds = constraints(&h); + assert!(kinds.iter().any(|k| matches!(k, ConstraintKind::Radius{value,..} if (*value-5.0).abs()<1e-7))); + assert!(kinds.iter().any(|k| matches!(k, ConstraintKind::ArcSweep{value,..} if (*value+std::f64::consts::FRAC_PI_2).abs()<1e-7))); + h.app.run("parameters.change", json!({"name":"arc_radius","expression":"8 mm"})).unwrap(); + let path = h.dir.join("typed-arc.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + let state = h.app.session.world_state(); + let sketch = &state.sketches[0].sketch; + let curve = &sketch.curves[0]; + assert!(curve.reversed, "negative typed sweep must remain clockwise"); + let CurveKind::Arc { c, a, .. } = curve.kind else { panic!("expected arc") }; + assert!((sketch.points[c].pos.dist(sketch.points[a].pos) - 8.0).abs() < 1e-6); + assert!(sketch.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::ArcSweep{value,..} if value<0.0))); +} + +#[test] +fn slots_keep_typed_length_and_width_as_dimensions_and_start_fresh() { + for command in ["sketch.slot.center_to_center", "sketch.slot.overall"] { + let mut h = start(command); + type_value(&mut h, "30"); + enter(&mut h); + assert_eq!(h.app.tool.as_ref().unwrap().dim_history[0].value, "30"); + h.step(&json!({"move":{"world":[15,5,0]}})).unwrap(); + type_value(&mut h, "6"); + enter(&mut h); + let kinds = constraints(&h); + assert!(kinds.iter().any(|k| matches!(k,ConstraintKind::Diameter{value,..} if (*value-6.0).abs()<1e-7)), "{command}"); + if command.ends_with("overall") { + assert!(kinds.iter().any(|k| matches!(k,ConstraintKind::SlotOverallLength{value,..} if (*value-30.0).abs()<1e-7))); + } else { + assert!(kinds.iter().any(|k| matches!(k,ConstraintKind::Length{value,..} if (*value-30.0).abs()<1e-7))); + } + h.step(&json!({"click":{"world":[50,20,0]}})).unwrap(); + h.step(&json!({"move":{"world":[70,20,0]}})).unwrap(); + let tool = h.app.tool.as_ref().unwrap(); + assert!(tool.dim_history.is_empty()); + assert!(tool.dims.iter().all(|d| !d.locked)); + type_value(&mut h, "12"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.tool.as_ref().unwrap().dim_history.is_empty()); + assert!(h.app.tool.as_ref().unwrap().pts.is_empty()); + } +} + +#[test] +fn invalid_stage_values_remain_editable_without_partial_geometry() { + let mut h = start("sketch.arc.center_point"); + type_value(&mut h, "-3"); + enter(&mut h); + assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1); + assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "-3"); + assert!(constraints(&h).is_empty()); + type_value(&mut h, "5"); + enter(&mut h); + type_value(&mut h, "360 deg"); + enter(&mut h); + assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 2); + assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "360 deg"); + assert!(constraints(&h).is_empty()); + type_value(&mut h, "90 deg"); + enter(&mut h); + assert_eq!(constraints(&h).iter().filter(|c| c.is_dimension()).count(), 2); + + let mut slot = start("sketch.slot.overall"); + type_value(&mut slot, "4"); + enter(&mut slot); + type_value(&mut slot, "6"); + enter(&mut slot); + assert_eq!(slot.app.tool.as_ref().unwrap().pts.len(), 2); + assert_eq!(slot.app.tool.as_ref().unwrap().dim_history[0].value, "4"); + assert_eq!(slot.app.tool.as_ref().unwrap().dims[0].value, "6"); + assert!(constraints(&slot).is_empty()); + type_value(&mut slot, "2"); + enter(&mut slot); + assert_eq!(constraints(&slot).iter().filter(|c| c.is_dimension()).count(), 2); +} + +#[test] +fn polygon_radius_is_persistent_and_fractional_sides_are_rejected() { + let mut h = start("sketch.polygon.inscribed"); + type_value(&mut h, "10"); + h.step(&json!({"key":"Tab"})).unwrap(); + type_value(&mut h, "5.5"); + enter(&mut h); + assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1); + assert!(constraints(&h).is_empty()); + type_value(&mut h, "5"); + enter(&mut h); + assert!(constraints(&h).iter().any(|c| matches!(c,ConstraintKind::Radius{value,..} if (*value-10.0).abs()<1e-7))); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert_eq!(sketch.curves.iter().filter(|c| !c.construction).count(), 5); +} + +#[test] +fn line_angle_survives_drag_and_one_undo_removes_geometry_and_dimensions() { + let mut h = start("sketch.line"); + let undo = h.app.session.undo.len(); + type_value(&mut h, "10"); + h.step(&json!({"key":"Tab"})).unwrap(); + type_value(&mut h, "30 deg"); + enter(&mut h); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.tool.as_ref().unwrap().dims.iter().all(|d| !d.locked), "next chained segment starts without stale locks"); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"drag":[{"world":[8.660254037844386,5,0]},{"world":[16,14,0]}]})).unwrap(); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + let CurveKind::Line { a, b } = sketch.curves[0].kind else { panic!("expected line") }; + let vector = sketch.points[b].pos - sketch.points[a].pos; + assert!((vector.len() - 10.0).abs() < 1e-6); + assert!((vector.angle() - 30f64.to_radians()).abs() < 1e-6); + // A drag may itself add an undo entry. Undo that before the original atomic draw. + while h.app.session.undo.len() > undo + 1 { + h.app.run("edit.undo", json!({})).unwrap(); + } + h.app.run("edit.undo", json!({})).unwrap(); + assert!(constraints(&h).is_empty()); + assert!(h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.is_empty()); +} + +#[test] +fn typed_drawing_preserves_construction_and_centerline_modes_at_undo_limit() { + for mode in [1, 2] { + let mut h = start("sketch.line"); + crate::sketch_palette::set_mode(&mut h.app, mode); + let snapshot = h.app.session.undo.last().unwrap().clone(); + h.app.session.undo.resize(200, snapshot); + type_value(&mut h, "10"); + h.step(&json!({"key":"Tab"})).unwrap(); + type_value(&mut h, "30 deg"); + enter(&mut h); + assert_eq!(h.app.session.undo.len(), 200); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert_eq!(sketch.curves[0].construction, mode == 1); + assert_eq!(sketch.curves[0].centerline, mode == 2); + h.app.run("edit.undo", json!({})).unwrap(); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert!(sketch.curves.is_empty()); + assert!(sketch.constraints.is_empty()); + crate::sketch_palette::set_mode(&mut h.app, 0); + } +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 681f41ba..adbefac1 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -31,6 +31,8 @@ pub struct Tool { /// Inline dimension boxes for the shape being drawn, the point count they were made for, /// and whether the first box should take the keyboard. pub dims: Vec, + /// Locked inputs from earlier stages of this shape (arc radius, slot length). + pub dim_history: Vec, pub dims_stage: usize, pub dims_focus: bool, } @@ -67,7 +69,17 @@ impl Tool { "sketch.constraint.symmetry" => ("sketch.constraint.symmetry", Kind::Pick(3)), _ => return crate::sketch_tools::tool_for(id), }; - Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new(), dims: Vec::new(), dims_stage: usize::MAX, dims_focus: false }) + Some(Tool { + cmd, + kind, + pts: Vec::new(), + hover: None, + picks: Vec::new(), + dims: Vec::new(), + dim_history: Vec::new(), + dims_stage: usize::MAX, + dims_focus: false, + }) } } @@ -79,7 +91,7 @@ pub fn hint(id: &str) -> String { "sketch.rectangle.three_point" => "3-point rectangle: click two points of one edge, then the width".into(), "sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(), "sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(), - "sketch.arc.center_point" => "Arc: click centre, start, then end (counter-clockwise)".into(), + "sketch.arc.center_point" => "Arc: click centre and start, then click end or type a signed sweep".into(), "sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(), "sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(), _ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(), @@ -100,7 +112,13 @@ 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(point) = app.tool.as_ref().and_then(|tool| crate::sketch_tools::periodic_start_snap(app, tool, proj, pos)) { + Some((point, Some("Close smooth loop".into()))) + } else 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; } @@ -171,40 +189,77 @@ pub fn place(app: &mut SolveApp, p: (Vec2, Option)) { app.tool = Some(tool); return; }; + if let Err(error) = crate::sketch_dims::validate(app, &tool) { + app.set_status(error, true); + app.tool = Some(tool); + return; + } tool.pts.push(p); if tool.cmd == "sketch.line" { if tool.pts.len() >= 2 { let (a, b) = (tool.pts[tool.pts.len() - 2].clone(), tool.pts[tool.pts.len() - 1].clone()); - if let Ok(v) = app.run("sketch.line", json!({"points": [arg(&a), arg(&b)], "infer": true})) { + let angled = crate::sketch_dims::has_locked(&tool, "Angle"); + let dimensioned = tool.dims.iter().any(|dimension| dimension.locked); + if let Ok(v) = draw(app, &tool, json!({"points": [arg(&a), arg(&b)], "infer": !angled})) { let curves: Vec = v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect(); - dimension(app, &tool, &curves); // Chain: the next segment starts at this one's end point. if let Some(id) = curves.first() { - crate::inference::commit(app, id, b.0); + if angled { + crate::inference::clear(); + } else { + // Endpoint inference is part of this drawing. Preserve its original + // undo history even when the history is already at the engine's cap. + let history = dimensioned.then(|| std::mem::take(&mut app.session.undo)); + crate::inference::commit(app, id, b.0); + if let Some(history) = history { + app.session.undo = history; + } + } let last = tool.pts.len() - 1; tool.pts[last].1 = Some(format!("{id}.end")); + // The chain needs only its current endpoint, even after many segments. + tool.pts = vec![tool.pts[last].clone()]; } + crate::sketch_dims::reset(&mut tool); + } else { + tool.pts.pop(); } } } else if n > 0 && tool.pts.len() >= n { - let curves = run_shape(app, &tool); - dimension(app, &tool, &curves); - tool.pts.clear(); + match run_shape(app, &tool) { + Ok(_) => { + tool.pts.clear(); + crate::sketch_dims::reset(&mut tool); + } + Err(error) => { + app.set_status(error, true); + tool.pts.pop(); + } + } } app.tool = Some(tool); } -/// Typed values become dimension constraints on the new curves. -fn dimension(app: &mut SolveApp, t: &Tool, curves: &[String]) { - for (ents, ty, value) in crate::sketch_dims::constraints(t, curves) { - let _ = app.run("sketch.dimension", crate::sketch_dims::dimension_params(&ents, ty, &value)); +/// Geometry and typed constraints are committed atomically, as one undo step. +fn draw(app: &mut SolveApp, t: &Tool, params: Value) -> Result { + let dimensions = crate::sketch_dims::dimensions(t); + if dimensions.as_object().is_none_or(|values| values.is_empty()) { + app.run(t.cmd, params) + } else { + let result = app.run("sketch.draw.dimensioned", json!({"command": t.cmd, "params": params, "dimensions": dimensions}))?; + // The internal wrapper has no toolbar category; apply the original creator's line + // type while retaining the draw's undo history, including at the history limit. + let history = std::mem::take(&mut app.session.undo); + crate::sketch_palette::apply_mode(app, t.cmd, &result); + app.session.undo = history; + Ok(result) } } -fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { +fn run_shape(app: &mut SolveApp, t: &Tool) -> Result, String> { let p = &t.pts; let d = |a: usize, b: usize| p[a].0.dist(p[b].0); - let sides = crate::sketch_dims::locked_value(app, t, "Sides").map_or(6, |v| v.round().clamp(3.0, 200.0) as usize); + let sides = crate::sketch_dims::locked_value(app, t, "Sides").map_or(6, |v| v as usize); let params = match t.cmd { "sketch.rectangle.two_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}), "sketch.rectangle.center" => json!({"center": xy(&p[0]), "corner": xy(&p[1])}), @@ -213,7 +268,10 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { "sketch.circle.two_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}), "sketch.circle.three_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "p2": xy(&p[2])}), "sketch.arc.three_point" => json!({"start": arg(&p[0]), "end": arg(&p[1]), "through": xy(&p[2])}), - "sketch.arc.center_point" => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}), + "sketch.arc.center_point" => match crate::sketch_dims::locked_value(app, t, "Sweep") { + Some(sweep) => json!({"center": arg(&p[0]), "start": arg(&p[1]), "sweep": sweep.to_degrees()}), + None => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}), + }, "sketch.polygon.inscribed" | "sketch.polygon.circumscribed" => { let a = (p[1].0 - p[0].0).angle().to_degrees(); json!({"center": xy(&p[0]), "radius": d(0, 1), "sides": sides, "angle": a}) @@ -225,14 +283,10 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "width": w.max(1e-3)}) } "sketch.point" => json!({"point": xy(&p[0])}), - _ => return Vec::new(), + _ => return Err(format!("drawing {} is not supported", t.cmd)), }; - let curves: Vec = app - .run(t.cmd, params) - .ok() - .map(|v| v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect()) - .unwrap_or_default(); - curves + let result = draw(app, t, params)?; + Ok(result["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect()) } fn run_pick(app: &mut SolveApp, t: &Tool) { @@ -264,6 +318,7 @@ pub fn finish(app: &mut SolveApp) { { t.pts.clear(); t.picks.clear(); + crate::sketch_dims::reset(t); return; } app.tool = None; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 300f3521..dfb11e18 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -15,6 +15,10 @@ use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad}; use crate::theme::Tokens; use crate::{SolveApp, icons}; +#[cfg(test)] +#[path = "viewport_appearance_tests.rs"] +mod appearance_tests; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NavMode { Orbit, @@ -214,11 +218,14 @@ fn scene_key(app: &SolveApp) -> u64 { (app.cam.pitch > 0.0).hash(&mut h); app.ui.show_sketches.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_occurrences.hash(&mut h); app.session.visibility.hidden_sketches.hash(&mut h); app.session.visibility.shown_sketches.hash(&mut h); app.ui.hidden_profiles.hash(&mut h); app.session.active_component.hash(&mut h); + app.session.active_occurrence_path.hash(&mut h); app.preview.replaced.hash(&mut h); + app.tree.redefine.hash(&mut h); crate::theme::is_dark().hash(&mut h); app.session.active_sketch.hash(&mut h); let (minor, _) = grid_step(app.cam.half_height()); @@ -264,12 +271,17 @@ fn build_scene(app: &SolveApp) -> GpuScene { sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w); } } + let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok()); for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) { + if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { continue; } // With a component active, bodies outside it are drawn faded. - let faded = s.active_component != 0 && !s.doc.component_within(s.doc.body_component(&b.name, b.feature), s.active_component); + let faded = if let Some(names) = &active_bodies { + !names.contains(&b.name) + } else { + s.active_component != 0 && !s.doc.component_within(b.component, s.active_component) + }; let base = body_color(&s.doc, b).unwrap_or(tk.body); let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) }; let m = b.mesh(); @@ -283,7 +295,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { let hidden_edges = app.ui.visual_style == 3; let hidden_col = c4(tk.text.gamma_multiply(0.35)); // Appearances: the body's opacity, and faces with looks of their own. - let opacity = s.doc.body_look(&b.name, b.feature).map_or(1.0, |l| l.opacity); + let opacity = s.doc.model_body_opacity(b); let face_looks: std::collections::HashMap = if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() }; for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded) { @@ -329,7 +341,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { // Section: the outline of each body where the plane cuts it. if let Some((o, n)) = section_plane(app) { let col = c4(tk.text.gamma_multiply(0.9)); - for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)) { + for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) { for (a, c) in section_segments(&b.mesh(), o, n) { sc.line(a.to_f32(), c.to_f32(), col, 1.8, false); } @@ -363,6 +375,12 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } } + let guide_unit = grid_step(app.cam.half_height()).0; + for points in crate::sketch_attachment_ui::guide_lines(app) { + for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) { + sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true); + } + } sc } @@ -370,7 +388,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) { // The bodies' box in the camera's Z-up frame (the ground is its XY plane). let mut b = solvecraft_engine::geom::Aabb3::EMPTY; - for x in st.bodies.iter().filter(|x| !app.session.visibility.hidden_bodies.contains(&x.name)) { + for x in st.bodies.iter().filter(|x| app.session.body_visible(&x.name)) { let bb = x.mesh().bounds(); if bb.is_empty() { continue; @@ -583,6 +601,9 @@ pub fn model_bounds(app: &SolveApp) -> solvecraft_engine::geom::Aabb3 { } } } + for point in crate::sketch_attachment_ui::guide_lines(app).into_iter().flatten() { + b.add(point); + } b } @@ -632,6 +653,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { app.session.active_sketch.hash(&mut h); app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_occurrences.hash(&mut h); app.ui.perspective.hash(&mut h); (proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h); let key = h.finish(); @@ -707,7 +729,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { // Nearest face along the ray (hides what is behind it). let mut bestf: Option<(f64, Hit)> = None; for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) { + if !app.session.body_visible(&b.name) { continue; } // Bodies whose box the ray misses (or enters behind the best hit so far) are skipped. @@ -733,7 +755,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { let mut bestv: Option<(f32, Hit)> = None; let mut beste: Option<(f32, Hit)> = None; for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) { + if !app.session.body_visible(&b.name) { continue; } // Only bodies whose outline on screen is near the cursor can have an edge under it. @@ -874,7 +896,7 @@ fn feature_geometry(app: &SolveApp, id: u64) -> Vec { let prefix = format!("F{id}:"); let mut out = Vec::new(); for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) { + if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { continue; } if b.feature == id { @@ -900,6 +922,14 @@ fn feature_geometry(app: &SolveApp, id: u64) -> Vec { fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec { match item { Sel::Feature { id } => feature_geometry(app, *id), + Sel::Occurrence { path } => app + .session + .occurrence_bodies(path, true) + .unwrap_or_default() + .into_iter() + .filter(|name| app.session.body_visible(name)) + .map(|name| Sel::Body { name }) + .collect(), _ => grouped_selection(&app.session, item), } } @@ -1070,13 +1100,18 @@ fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, fac } } -fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) { +pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) { let st = app.session.world_state(); let Some(ss) = st.sketch(sketch) else { return }; let Some(p) = ss.profiles.get(index) else { return }; + let plane = match crate::dialogs::emboss_profile_plane(app, sketch) { + Ok(Some(plane)) => plane, + Ok(None) => ss.plane, + Err(_) => return, + }; // Lift toward the eye so a profile on a face shows over it. let lift = app.cam.back() * (app.cam.half_height() * 0.002); - let w = |q: solvecraft_engine::geom::Vec2| (ss.plane.to_world(q) + lift).to_f32(); + let w = |q: solvecraft_engine::geom::Vec2| (plane.to_world(q) + lift).to_f32(); let col = c4(fill); // In the translucent pass (after the grid and edges) so it covers them. for [a, b, c] in p.region.triangulate(0.02) { @@ -1120,6 +1155,7 @@ fn highlight_key(app: &SolveApp) -> u64 { app.origin_visible().hash(&mut h); app.session.visibility.hidden_origin.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_occurrences.hash(&mut h); app.cam.half_height().to_bits().hash(&mut h); app.cam.back().x.to_bits().hash(&mut h); h.finish() @@ -1202,14 +1238,14 @@ fn build_highlight(app: &SolveApp) -> GpuScene { match x { Sel::Face { body, index, .. } => { // A live preview stands in for the body: its old faces would fight with it. - if let Some(b) = st.body(body).filter(|_| !app.preview.replaced.contains(body)) { + if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) { face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false); hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge); } } Sel::Body { name } => { if let Some(b) = st.body(name) - && !app.session.visibility.hidden_bodies.contains(name) + && app.session.body_visible(name) { let m = b.mesh(); for f in 0..b.body.face_count() { @@ -1219,6 +1255,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } Sel::Edge { body, index, point } => { if let Some(b) = st.body(body) + && app.session.body_visible(body) && let Some(e) = edge_of(&b.mesh(), *index, *point) { edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0); @@ -1253,7 +1290,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { // Timeline modifiers light only their resulting faces; browser rows light whole bodies. let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default(); hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() })); - for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name) && !app.preview.replaced.contains(&b.name)) { + for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name)) { let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name)); let faces: Vec<_> = if whole { (0..b.body.face_count()).collect() @@ -1768,6 +1805,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::drag_snap::show(app, &painter, &proj); hover_highlight(app, &painter, &proj); points_2d(app, &painter, &proj); + if let Some((plane, sketch)) = crate::sketch_attachment_ui::lost_guide(app) + && let Some((low, high)) = sketch.bounds() + && let Some(at) = proj.to_screen(plane.to_world(solvecraft_engine::geom::Vec2::new((low.x + high.x) * 0.5, high.y))) + { + painter.text(at - vec2(0.0, 12.0), Align2::CENTER_BOTTOM, "Saved sketch plane — attachment lost", FontId::proportional(12.0), t.warning); + } if let Some(bx) = app.viewport.boxsel { let col = if bx.crossing { t.box_crossing } else { t.box_window }; painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12)); @@ -1936,7 +1979,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { } else { let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES); for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) { + if !app.session.body_visible(&b.name) { continue; } let m = b.mesh(); diff --git a/crates/ui-egui/src/viewport_appearance_tests.rs b/crates/ui-egui/src/viewport_appearance_tests.rs new file mode 100644 index 00000000..1cfacedc --- /dev/null +++ b/crates/ui-egui/src/viewport_appearance_tests.rs @@ -0,0 +1,54 @@ +use super::build_scene; +use crate::{Services, SolveApp}; +use serde_json::json; +use solvecraft_engine::{Session, doc, geom::Vec3, kernel}; + +#[test] +fn placed_step_body_uses_imported_opacity_until_a_design_look_overrides_it() { + let body = kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)) + .unwrap() + .with_color(Some([0.2, 0.6, 0.8])) + .with_paint(Some(kernel::Paint { opacity: 0.4, faces: Vec::new() })); + let step = + kernel::step_export_bodies(&[kernel::ExportBody { name: "Imported glass".into(), body: &body, color: body.color() }], &Default::default()) + .unwrap(); + let mut document = doc::Document::default(); + let component = document.add_component(Some("Imported part"), 0).unwrap(); + let feature = document.add_feature(doc::FeatureKind::Import { file: "glass.step".into(), step, components: Vec::new() }, None).unwrap(); + document.features.iter_mut().find(|f| f.id == feature).unwrap().component = component; + let mut session = Session::new(document); + let source = session.model.state().bodies[0].name.clone(); + assert!((session.doc.model_body_opacity(&session.model.state().bodies[0]) - 0.4).abs() < 1e-6); + session.execute("occurrence.copy", &json!({"component":component,"translate":[30,0,0]})).unwrap(); + let mut app = SolveApp::new(session, Services::default()); + app.ui.ground_shadow = false; + app.ui.show_origin = false; + let placed = app.session.world_state().bodies.iter().find(|b| b.name != source).unwrap().name.clone(); + app.session.selection = vec![solvecraft_engine::Sel::Body { name: placed }]; + app.start("appearance.assign"); + let crate::dialogs::Kind::Appearance(appearance) = &app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") }; + assert!((appearance.opacity - 0.4).abs() < 1e-6); + assert_eq!(appearance.color, [51, 153, 204]); + crate::dialogs::cancel(&mut app); + let scene = build_scene(&app); + assert!(!scene.glass.is_empty()); + assert!( + scene.tris.is_empty(), + "opaque triangles {}, bodies {:?}", + scene.tris.len(), + app.session.world_state().bodies.iter().map(|b| (&b.name, b.body.paint(), &b.imported)).collect::>() + ); + let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(remainder.is_empty()); + assert!(vertices.iter().all(|v| v[27] == 102)); + app.run("appearance.assign", json!({"bodies":[source],"color":"#336699","opacity":0.7})).unwrap(); + let scene = build_scene(&app); + let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(remainder.is_empty()); + assert!(vertices.iter().all(|v| v[27] == 179)); + app.run("appearance.assign", json!({"bodies":[source]})).unwrap(); + let scene = build_scene(&app); + let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(remainder.is_empty()); + assert!(vertices.iter().all(|v| v[27] == 102)); +} diff --git a/crates/ui-egui/tests/scenarios/face_wins_over_origin_plane.json b/crates/ui-egui/tests/scenarios/face_wins_over_origin_plane.json index 18f2f4fd..6d8df411 100644 --- a/crates/ui-egui/tests/scenarios/face_wins_over_origin_plane.json +++ b/crates/ui-egui/tests/scenarios/face_wins_over_origin_plane.json @@ -60,6 +60,18 @@ ] } }, + { + "note": "The shelled floor is picked rather than the origin plane. Its uncertified mesh face requires an explicit fixed-plane choice." + }, + { + "expect": { + "sketching": false, + "dialog": "this face has no exact planar source" + } + }, + { + "widget": "Use fixed plane" + }, { "expect": { "sketching": true @@ -78,4 +90,4 @@ } } } -] \ No newline at end of file +] 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/s22_sheet_metal_fold.json b/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json index 4990a63c..cf87dada 100644 --- a/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json +++ b/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json @@ -80,6 +80,28 @@ ] } }, + { + "note": "The sheet flange face is uncertified; choose the displayed fixed-plane option explicitly before drawing the fold line." + }, + { + "expect": { + "sketching": false, + "dialog": "this face has no exact planar source" + } + }, + { + "widget": "Use fixed plane" + }, + { + "expect": { + "sketching": true, + "query": { + "command": "sketch.inspect", + "path": ["plane", "origin", 2], + "value": 2.5 + } + } + }, { "start": "sketch.line" }, @@ -149,4 +171,4 @@ "file": "Untitled-Body1.dxf" } } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json index d11d2245..7c2db2ac 100644 --- a/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json +++ b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json @@ -151,7 +151,7 @@ "key": "Enter" }, { - "widget": "Capture Position" + "widget": "Capture All Positions" }, { "expect": { @@ -199,4 +199,4 @@ { "shot": "s29_moved" } -] \ 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/s61_occurrence_browser.json b/crates/ui-egui/tests/scenarios/s61_occurrence_browser.json new file mode 100644 index 00000000..d5f3aa10 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s61_occurrence_browser.json @@ -0,0 +1,22 @@ +[ + {"note":"Each instance has a selectable full path. The second row targets its placement, eye and fold independently."}, + {"call":"engine.execute","params":{"command":"component.create","params":{"name":"Block"}}}, + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10}}}, + {"call":"engine.execute","params":{"command":"component.activate","params":{"component":0}}}, + {"call":"engine.execute","params":{"command":"occurrence.copy","params":{"component":"Block","translate":[50,0,0]}}}, + {"click":{"handle":"row:occurrence:4"}}, + {"expect":{"selection":1}}, + {"call":"ui.menu","params":{"x":300,"y":300,"target":{"type":"occurrence","path":[4]}}}, + {"call":"ui.menuPick","params":{"item":"Properties"}}, + {"expect":{"shown":"(55.00, 5.00, 5.00) mm","query":{"command":"occurrence.properties","params":{"occurrence":4,"path":[4]},"path":["total","center_of_mass",0],"value":55}}}, + {"shot":"second_occurrence_properties"}, + {"click":{"handle":"eye:occurrence:4"}}, + {"click":{"handle":"fold:occurrence:4"}}, + {"shot":"second_occurrence_hidden_folded"}, + {"click":{"handle":"eye:occurrence:4"}}, + {"click":{"handle":"activate:occurrence:4"}}, + {"call":"ui.menu","params":{"x":300,"y":300,"target":{"type":"occurrence","path":[4]}}}, + {"call":"ui.menuPick","params":{"item":"Move/Copy"}}, + {"expect":{"shown":"Move: Block:2"}}, + {"shot":"second_occurrence_move"} +] 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..ce65b204 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -11,6 +11,13 @@ component, and **Paste New** creates an independent copy. Move an occurrence wit triad, then **Capture Position** to keep it or **Revert** to put it back. SolveCraft asks before the next command if a move wasn't captured. **Ground** fixes an occurrence in place. +Paste New copies nested component definitions, sketches, feature histories and generated +dimension parameters. Repeated child instances remain shared within the new copy. Materials, +appearances and wholly internal joints/motion records receive copied references. Design-wide +user parameters remain shared; retained external links and omitted cross-boundary motion +records are reported. Unsupported destructive links refuse the entire copy. See +[individual-instance and copy details](https://github.com/bherbruck/solvecraft/blob/main/docs/occurrence-workflows.md). + **Joints** (`joint.create`, J) connect two components by joint origins that snap to faces, edges, circles and vertices: rigid, revolute, slider, cylindrical, pin-slot, planar and ball, with offsets, flip and limits. **As-built Joint** keeps the current position, **Rigid Group** locks @@ -20,3 +27,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/features.md b/docs/book/src/guide/features.md index 555f0561..9aeff456 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -18,6 +18,32 @@ Extruding into existing material selects **Cut**. In an empty component it selec choose **New Body** explicitly to keep an addition separate. Changing the default does not alter operations already saved in a design. +Editing an extrusion retains its signed distances, From/To Object inputs, taper expressions +and explicit body targets. Revolve edits retain world, sketch and explicit-line axes, both +angle expressions and To Object targets. An unchanged **All profiles** selection continues +to include profiles added later. Cancel restores the complete timeline; a successful edit is +one undo step, including downstream parameter changes. Editing a placed component previews the +edited feature before its downstream history. Cylindrical Emboss profile guides follow the +sketch's chosen occurrence frame. + +New symmetric revolves split the entered angle evenly around the sketch profile, regardless +of which way the selected axis points. Two Sides measures the second angle backward and the +first angle forward along the sketch-normal sweep direction. Profiles on opposite sides of +an axis each use their own sweep direction. Designs saved before format 11 keep their original +angular placement when opened, edited or copied; creating another revolve uses the new behavior. +To Object follows that same direction for each selected profile, including curved target faces. +Pick a target away from the axis so its angular position is defined. + +For cylindrical **Emboss**, select a supported exterior cylinder face and a tangent sketch +profile. **Deboss** cuts radially into the wall. Orthogonal line profiles, including holes and +concave outlines, wrap with exact radial walls; the profile must stay clear of seams and ends. +Complete cylinders and coaxial sleeves are supported. Capture pending component positions +before applying. The feature stores the selected target and author occurrence paths, so edits +and reopening retain the chosen instances. Other curved surfaces and free-form profiles remain +unsupported. A new Emboss without an explicit face infers a unique eligible supported cylinder; +select the face when the target is ambiguous. Older saved features without a face retain their +planar placement. + Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling ball). See the roadmap's known limitations for the rest. @@ -28,3 +54,7 @@ be found, the feature warns instead of silently picking another face. Press **Delete** on anything selected (bodies, faces, sketches, features, planes, components, canvases). It runs as one undoable step and lists the features that would go with it or fail. + +Face Extrude, face Revolve, and Press Pull retain linked generated profiles. When a modified planar face has no construction certificate, these internal profiles use the legacy named-face attachment and projection behavior so upstream edits still update the plane and outline. This compatibility path is private to those modeling tools: newly attached user sketches require a certified face or an explicitly fixed plane. Legacy internal attachments can follow the nearest matching face after topology changes; inspect their result after major upstream edits. Activate the intended occurrence before generating a face profile from a repeated component; capture a separately moved source position before creating a legacy profile across components. + +A negative Press Pull across a planar face containing a round through-hole can still fail when the Boolean cutter has coincident curved faces. This operation reports an error and preserves the design atomically; positive face extrusion and Press Pull retain the hole and remain linked. diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 663bd9c2..c041953d 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -17,3 +17,12 @@ Saves are atomic: a crash at any moment leaves either the old file or the new on replaced version is kept as `.bak`. Unsaved changes are autosaved to a recovery folder, and the next launch offers to recover them. Older design files are upgraded on open, and files from a newer version are refused with a message. + +Current designs use format 10 for recorded body transfers, explicit 3D curve coordinate +spaces and certified face bindings, alongside semantic configuration styles, associative +free-form offsets and cylindrical emboss selections. Older ownership maps, geometry and +expressions retain their saved meaning. Legacy 3D curves with retained associations receive +their producer's coordinate-space marker without changing any saved points; detached curves +retain world coordinates. A lost cross-component association refreshed by an older build may +need an explicit relink or refresh when its source returns. Loading never guesses a numerical +correction. Association records, coordinate arrays and placements are bounded and checked. 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..46c803e5 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -9,7 +9,47 @@ 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. + +Base-flange profiles keep their interior holes. Through cuts that cross a sheet edge become +notches in the flat outline; overlapping and repeated cuts remove their combined area once. +The flat preview, DXF and unfolded solid use the same material boundaries. Lines and circular +arcs remain exact; other profile curves use a 0.001 mm chord tolerance. + +Cuts can leave separate material pieces, including the centre of a ring cut. These pieces +remain one logical sheet group through Unfold / Refold and export as separate flat outlines. +Large profiles that exceed the geometry processing budget report an error instead of +exporting a partial outline. + +Parallel, untapered extrude cuts also preserve shallow pockets through Unfold / Refold. +The pocket floor and side walls remain solid geometry; overlapping cuts from opposite sides +become a through hole wherever they remove the whole thickness. Flat-pattern JSON lists +`pockets` with their side, depth and bottom/top thickness coordinates in millimetres. +DXF pocket boundaries use `POCKET_BOTTOM_Z…_TO_…_MM`, `POCKET_TOP_Z…_TO_…_MM`, or +`POCKET_INTERNAL_Z…_TO_…_MM` layers. These are machining operations, so do not treat every +pocket layer as a through-cut contour when importing the drawing into a CAM tool. + +Extrude Intersect on a straight base or an unfolded sheet retains the exact planar material, +including interior holes and detached islands. A completely empty intersection removes the +sheet and its metadata together. Pocket profiles can move with planar panels when forming, +but a pocket crossing a bend zone cannot yet be refolded. Machining folded bend material, +tapered sheet cuts and other unsupported 3D sheet Cut/Intersect operations report an error before changing +the design; they cannot silently restore removed material during a later sheet rebuild. diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 85c7c25e..b74d1e30 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -20,6 +20,43 @@ ids of what it added (`created: {points, curves, constraints, dimensions}`). Ids never reused within a sketch, even after a delete, so a script can dimension or constrain the new geometry right away. +Extend finds intersections with exact ellipses, rational conics and spline spans. It keeps the +original line or circular support and extends only beyond the selected endpoint; conflicting +constraints or an ambiguous overlap leave the sketch unchanged. + +Offsets of smooth ellipses, conics and splines follow their source after dimension changes, +dragging and reopening a design. Their stored cubic spline has a whole-span normal-distance +error bound of 0.002 mm. Singular tangents, curvature cusps, possible self-intersections and +excessive subdivisions are refused. Offset these curves individually: mixed chain junctions +currently require separate geometry. `sketch.offset.break` releases an offset for independent +editing, and undo restores its association. Older independently sampled offsets remain independent. +Smooth open and newly created smooth closed offsets can be offset again, including bounded +refinement after a source edit. New closed outputs use a periodic cubic fit with continuous +tangent and curvature at the seam. Break Offset Association preserves that interpolation, +and its closing point remains joined when dragged. Deleting an output also removes its +unused private fit points; deleting a source with a retained dependent is refused. + +For explicit closed fit splines, `sketch.spline.fit_point` accepts `periodic: true` and at +least three distinct cyclic samples. It closes the loop automatically. Existing calls and +older saved closed splines retain natural interpolation; older associative closed offsets +also retain their saved natural fit. A legacy seam without a unique normal still refuses +another offset. Periodic intent is stored on each curve and survives save, reopen and Break. +While drawing a fit spline, click its starting point after three distinct samples to close +it smoothly. Right-click finishes an ordinary natural fit. Offset accepts both ellipse +handles and dense fitted curves; the second click sets the side and distance from the curve. + +Enable **3D Sketch** in the Sketch Palette to draw lines, fit splines and points off the plane. +New 3D geometry follows its sketch's component placement, including the selected occurrence +and pending position changes. Click a drawn fit point to show its move triad; its arrows move +along world X, Y and Z. Scripts also supply world coordinates to `sketch.line3d`, +`sketch.spline3d`, `sketch.point3d` and `sketch.move3d`. Activate the desired occurrence before +editing an explicit sketch belonging to a component with multiple placements. + +Older drawn or detached 3D wires keep their saved world positions. Included and surface-linked +wires with an explicit component marker keep that meaning after the link is broken. Old files +whose links became lost after an incorrectly scoped cross-component refresh may require a +fresh link once the source is available; their stored point values are preserved on opening. + ## Constraints and dimensions The solver is SolveCraft's own (damped Gauss–Newton with degree-of-freedom analysis). Constraints: diff --git a/docs/configurations.md b/docs/configurations.md new file mode 100644 index 00000000..5125fdd3 --- /dev/null +++ b/docs/configurations.md @@ -0,0 +1,151 @@ +# Design configurations + +The configuration table stores named design variants. Add parameter, feature suppression, +physical material, body appearance, component appearance, and semantic face 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`. + +Body 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 appearances + +**Add Column** offers component appearances and a **Face Appearance** submenu organized by +body and semantic role. You can also select a viewport face before opening the menu and use +**Selected face on ...**. These assignments affect every occurrence sharing the definition. +Component columns use stable component IDs, so renaming a component preserves its rows. +Face columns resolve a definition body through certified lineage, never through a saved display face index. + +```json +{"command":"config.configure","params":{"component_appearances":["Part"],"face_appearances":[{"body":"Box1","point":[5,5,10]}]}} +``` + +Use a point on the current surface to capture a face. The menu carries its already certified target directly, avoiding a second nearest-face pick on thin concentric walls. World occurrence picks are converted +to the shared definition coordinates. `config.table` returns canonical keys and readable +target labels. Use its returned face key when editing a row or deleting a face column: +`config.column` accepts `face_appearance: ""` with `delete: true`. +Component columns can be removed with `component_appearance: "Part"` and `delete: true`. + +Supported stable face roles are box axis faces, cylinder caps and unsplit side, profile-plane +extrude caps, and one-sided revolve caps. From/nonzero or variable Start Offset/Two Sides extrude caps and +symmetric/Two Sides revolve caps are not yet certified. Mesh faces, ambiguous cap names, +generic index-derived names, and sketch side names based on nearest-curve heuristics are +rejected. Untapered New Body extrusions additionally support line, circle and arc sides +with exact source-edge construction certificates. Ordinary face names and saved sketch or Emboss anchors retain their legacy naming; strict styles resolve separate source metadata and also require the original producing feature. All constructed pieces of the same +sketch curve share one appearance, including split circle seams; distinct coincident +curves are ambiguous and cannot be selected. Unsupported downstream modifiers remove +the certificate rather than transferring paint by proximity. Coplanar split pieces of one supported semantic face share +the configured appearance. If that lineage disappears while its body remains, Apply and +Preview reject the row atomically. There is no nearest-face substitution; later ordinary +topology edits also cannot transfer its paint to a replacement face. A suppressed owning +feature keeps the assignment dormant until it returns. + +The Library's **Follow inherited appearance** removes only the selected direct component +or face override. Face paint takes priority over body, component, and physical material; +clearing it reveals inherited or imported paint. Body renames maintain face targets. +Deleting a component or originating feature removes its configured targets and row cells. +Deleting one occurrence preserves the shared definition configuration. + +Embedded STEP faces use certificates bound to the exact stored payload, source item and +original STEP face entity. Kernel seam pieces of that same source face share the override. +Replacing the embedded payload invalidates the old face target, even when the replacement +has identical geometry or reuses entity numbers. Body renames and exact rigid placements +preserve source identity; ordinary parameter edits rebuild construction certificates. + +Independent component copies duplicate mapped configuration columns, row values, and rules +without retargeting the source. An independent copy of the same embedded STEP payload +preserves its source certificate under the new feature. Copying strict paint on a foreign +body that requires re-export still fails explicitly because that exporter cannot yet +certify the source-to-output face mapping. + +Activation evaluates all row parameter expressions together, so column order does not create +temporary dependency cycles. Preview and activation reject new or changed geometry errors +without changing the design. An identical existing error remains visible. A saved row cannot +recreate a deleted parameter: remove its configuration references before deleting it. Deleting +a feature removes its input and suppression columns and any rules referring to that feature. + +## Ordered rules and preview + +Open **Add or edit rule** below the table. Give the rule a name, choose when it applies, then +choose a driven parameter, driven suppression, numeric range, or allowed values. Numeric +fields accept ordinary design expressions and explicit units. **Edit**, the arrow buttons, +the enable checkbox, and **Delete** update existing rules. Choose **All**, **Any**, or **Not** +to build compound conditions with nested controls, **Add condition**, and **Remove condition**. + +**Preview Row** rebuilds a scratch design and shows effective expressions and matched or +skipped rules. Driven and restricted values identify the rule responsible. It leaves the +current geometry, active row, and undo history unchanged. **Apply** or **Activate** publishes +the validated variant as one undoable action. + +Rules execute in list order. Each condition sees earlier drives; restrictions from matched +rules all apply to the final values. A range includes its endpoints. Allowed values compare +numeric values with matching dimensions, so `1 cm` equals `10 mm`. An angle never equals a +length. Bare expressions use the target parameter's unit, or the document's unit for feature +length inputs. Equality uses a relative/absolute tolerance of `1e-9` in internal units. +The rule editor's **Length units** selector sets the meaning of bare feature-length +expressions; explicit units override it. Rules capture this unit context when authored. Changing document units +preserves mixed expressions such as `width + 1` without treating `width * 2` as a length +multiplier. Bare saved feature length cells and rule thresholds gain the previous explicit +unit; explicit units and nonliteral source expressions retain their text. A matching active +row rederives its driven values when units change. User parameters retain their own units. + +Rules recompute on configured edits, **Apply**, and **Preview Row**. Ordinary parameter +edits can leave the configuration unmatched and do not continuously enforce restrictions. +Cross-unit drives store effective numeric lengths; their saved source expressions reevaluate +on the next Apply or Preview rather than following later ordinary parameter edits live. +Saved source cells are preserved: disabling a drive restores the row's original choice. + +```json +{"command":"config.rule","params":{"rule":{"id":"depth-rule","name":"Large parts use deeper stock","condition":{"type":"parameter","parameter":"width","operator":"ge","expression":"80 mm"},"effect":{"type":"drive_parameter","parameter":"depth","expression":"width / 2"}}}} +{"command":"config.rule","params":{"rule":{"id":"width-range","name":"Stock width","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"width","minimum":"10 mm","maximum":"100 mm"}}}} +{"command":"config.preview","params":{"row":"Aluminum blue"}} +{"command":"config.rule","params":{"id":"width-range","index":0}} +``` + +Configure the `depth` column before adding this example drive. Conditions may reference any +existing design parameter; effect targets must be configured columns. `config.rules` lists +rules. `config.rule` replaces a rule with the same ID, deletes with `id` and `delete: true`, +or moves it to a zero-based `index`. Editing a rule validates every saved row before committing; +editing a row validates that row. Invalid geometry, dependency cycles, incompatible units, +or restrictions leave the previous document intact and explain the failure. Delete rules +before removing the last row. Parameter renames update rule targets and expressions; rule +references prevent parameter deletion until removed. + +Tables support at most 128 rules; conditions allow eight levels and 64 nodes, and allowed +value lists support up to 128 expressions. These bounds are checked before saved rules are +decoded. Driven appearance/material rules and repeated fixed-point evaluation are outside +the current rule scope. + +Newly saved designs use file format 11, including optional durable face attachments. 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/face-attachments.md b/docs/face-attachments.md new file mode 100644 index 00000000..d96f60a6 --- /dev/null +++ b/docs/face-attachments.md @@ -0,0 +1,66 @@ +# Face-attached sketches + +New face-attached sketches store the selected source body, its creating feature, an exact +face certificate, and the source and author occurrence paths. The sketch's picked origin +and orientation are stored in the source face's canonical plane. Editing the source height +or moving its placement therefore updates the sketch plane while preserving its local +geometry and dimensions. + +Supported planar sources are exact Box faces, untapered New Body Extrude caps and straight +sketch sides, and planar faces of immutable STEP imports. Extrude caps also identify their +outer boundary and holes by source curve IDs. Reordering profiles cannot substitute another +profile's cap. Curved surfaces cannot host a planar sketch through this workflow. +Edited surfaces without retained construction provenance, ambiguous sources, and unsupported +generators require an explicit fixed plane instead. A nearby or parallel face is never used +to repair a new attachment automatically. + +## Create and repair + +Select a supported planar face and create a sketch. The desktop selection supplies the exact +body and face index, including its occurrence path. Command callers can use +`sketch.create` with `plane: {face: [x,y,z], body: "world body key", index: face_index}`; +the point is in world coordinates. A point-only face request must find a unique supported +source. An unsupported or ambiguous request fails with an explanation instead of creating +an attachment with weaker identity. + +When a source is suppressed, deleted, replaced, or loses its certificate, the sketch reports +a lost attachment. Its saved plane is only a visible repair guide: it does not produce a +healthy solved sketch or let downstream features build at a substitute face. + +Use **Redefine Sketch Plane** to select a new supported face explicitly. The draft can be +reviewed before applying it; cancellation leaves the existing attachment intact. Reattachment +keeps existing sketch geometry, IDs, and dimensions. When the selected source changes, its +previous automatically projected boundary becomes ordinary geometry on the new plane; +it is not replaced by a newly projected boundary. Rebinding to the same certified source +retains its live boundary. Manually created projection links retain their own sources. + +Choose an explicit fixed plane to detach the sketch. This preserves its current local +geometry and dimensions and converts only its automatically projected face boundary to +ordinary geometry. Manual projection links remain attached to their own sources. A fresh +fixed-plane sketch has no live face auto-projection. + +## Occurrences, pending positions, and files + +The selected source occurrence and the sketch's author occurrence are separate identities. +Repeated instances therefore cannot silently switch an attachment to the first placement. +A missing author path also requires explicit repair. Reattachment and creation capture +pending positions only on the involved source and author paths. Failed commands restore +those pending positions; undo restores the previous attachment and placement state. + +File format 11 saves these optional attachments. Existing designs with face references +without an attachment retain their legacy geometry and lookup behavior when opened; they +are not silently upgraded. Redefining such a sketch onto a supported selected face creates +the stronger attachment. Unsupported legacy geometry can instead be retained on an explicit +fixed plane. Imported attachments also bind the exact STEP payload digest, item entity, and +face entity: replacing the payload cannot reuse an old attachment merely because a face +index or shape looks the same. + +## Profiles generated by face modelling commands + +Extrude, Revolve, and Press Pull can generate an internal sketch from a selected planar face. Supported +sources use the same certified attachment and live boundary as an ordinary attached sketch. +For modified faces without a construction certificate, these commands retain their historical +named-face and linked-boundary behaviour through a private compatibility path. Initial capture +uses the selected body and face index. These helper sketches do not have the stronger saved +identity guarantee of a certified attachment. This compatibility path does not change the +requirements for creating an ordinary face-attached sketch. diff --git a/docs/occurrence-workflows.md b/docs/occurrence-workflows.md new file mode 100644 index 00000000..9152ad9a --- /dev/null +++ b/docs/occurrence-workflows.md @@ -0,0 +1,61 @@ +# Working with individual instances + +Each occurrence placement has a full path of occurrence IDs, from the design root to that placement. A child definition reused under several parent instances has a different full path in each parent. Names are labels; paths identify placements across rename and position capture. + +Select a placement through `select.set`: + +```json +{"items":[{"type":"occurrence","path":[2,4]}]} +``` + +Properties accepts the leaf occurrence ID and the full path. Move can use world coordinates, including a world rotation pivot: + +```json +{"occurrence":4,"path":[2,4],"space":"world","translate":[10,0,0],"capture":false} +``` + +World movement includes pending parent and child positions. The default space remains parent-local for existing scripts. Capture Position commits pending transforms; Revert Position drops them. Copying an occurrence copies that selected occurrence's effective transform, including its pending position. + +An occurrence definition inside a component is shared by every instance of that parent component. Moving, grounding, copying, or deleting that nested definition changes all its parent instances. Selecting its exact path determines the world frame used for movement and Properties; it does not create an independent child definition. + +Activate a placement to author shared geometry through its frame: + +```json +{"component":3,"occurrence":4,"path":[2,4]} +``` + +New geometry belongs to the component and appears in every instance. The selected frame determines how world inputs become local component geometry. Planar sketches and new 3D wires display at the active placement during authoring. Component-only activation keeps the first-placement behavior. Format 10 stores an explicit World or Component coordinate marker for 3D wire samples and fit points. Older drawn and detached wires retain their world positions; retained Include and surface producer links keep their component meaning, including lost references. Breaking a link preserves its explicit marker. Activate the desired occurrence before supplying world inputs to an explicit sketch owned by a repeated, nonactive component. + +Visibility is an independent view setting for exact placements. Hide a path and all descendants with `browser.visibility`: + +```json +{"visible":false,"paths":[[2,4]]} +``` + +`visible:true` removes that path's own hide setting. A hidden ancestor still hides its descendants. Add `isolate:true` to a visible path list to hide other branches. Show the bodies folder to clear occurrence hides. Selection, activation paths and visibility are session state; the saved document retains occurrence IDs and transforms. Undo follows the existing behavior of clearing selection. + +Paths are checked for missing links, repeated IDs, component mismatches and a maximum depth of 64. Descendant expansion rejects more than 100,000 placements or bodies instead of returning incomplete results. Computed body names normally retain their readable occurrence labels; collisions use full occurrence IDs so visibility and body commands still address the correct source. + +Placed bodies retain their definition identity separately from display names. Body materials and appearances remain shared, with body overrides taking precedence over component overrides and material colors. Face overrides follow the placed geometry, including pending positions, and painted exports retain these colors. + +Paste New copies the reachable component definition graph and its feature history. Repeated children stay shared within the new copy, while the copied definitions, sketches and generated dimension/input parameters are independent of the originals. Materials and body, component and face appearances follow the copied definition identities. Pattern and mirror output names remain explicit, so later body operations follow the copied bodies. + +Configuration columns, row values and internal rules are duplicated for copied targets. Certified native face appearances follow the new feature lineage. Selected cylindrical Emboss target and authoring paths inside the copied graph follow the copied occurrences; an incompatible external authoring context is rejected before adoption. + +A body reassigned from an external component receives an independent BaseFeature before the first copied feature that modifies it. Copied modifiers then replay once. The command reports these frozen bodies. Public user parameters remain design-wide; retained external sketch, project and feature links are reported. Joints and motion records wholly inside the copied occurrence graph receive new references. Records crossing its boundary are omitted with notices. + +Copy evaluates on a scratch document, checks that original bodies remain unchanged, and verifies copied body measurements and sampled material occupancy before adopting it. Unsupported cross-boundary destructive operations, configured foreign faces without certified semantic lineage, reassigned mesh bodies, rolled-back timelines and exceeded work limits return errors without a partial copy. A successful copy selects the new occurrence, preserves your active component and unrelated pending positions, and places the copy under the chosen parent occurrence path. Activate the new occurrence to author features in it. Paste New preserves each 3D wire's World or Component marker and stored coordinates: Component wires follow the copied placement, while World wires retain their physical world positions. + +Create Components from Bodies and Move Bodies to Component now add ownership-transfer features at the current timeline position. Earlier joins, cuts and face selections keep their original component frames. At the transfer, geometry converts once into the destination frame; later features authored there operate on the transferred body. Rolling the timeline back before the transfer displays the original owner. Body materials and face identity survive the transfer, and an inherited source-component appearance becomes a body override to preserve its visible look. + +Transfers preserve the body's world pose through rotated or translated parents. Pending positions on the involved source and destination paths are captured atomically with the transfer so save/reopen retains that pose; unrelated pending positions remain pending. Undo and checkpoint restoration recover the original pending positions as well as the feature history; redo restores the captured transfer. The operation requires one source and one destination placement. Shared instance definitions with several placements are rejected because a single ownership change cannot preserve several different world poses. A component that supplies transfer history outside its subtree cannot be deleted until that dependent history is removed. + +Older files without transfer features keep their saved assignment and offset behavior. No transfer timestamp is inferred from their final ownership map. Paste New freezes externally authored geometry after its successful entry transfer and remaps internal transfers, then replays later modifiers once. All transfer commands validate and evaluate on a scratch document before adoption. + +New face-attached sketches capture the selected body's producer, certified face and exact occurrence path, together with the sketch's authoring placement. A supported face follows source edits; a missing or incompatible source reports a lost attachment rather than choosing another nearby face. Opening Edit restores the saved authoring occurrence as view context without creating an undo entry. Older saved point-only face references retain their legacy behavior. + +`sketch.create` accepts `plane:{face:[x,y,z],body:"world body name",index:faceIndex,path:[occurrenceIDs]}`. Coordinates and custom planes are world inputs. The optional path must match the selected body. Point-only face picks must resolve uniquely to a supported exact attachment. For an unsupported planar face, explicitly add `fixed:true` to keep its current plane; this creates no live boundary projection. Curved faces cannot become planar attachments through this option. + +Use `sketch.redefine` with the sketch ID and a new plane to repair or convert an attachment. Its preview is disposable; Apply records one undo step. An optional `author_path` explicitly repairs a missing author placement. Rebinding a repeated owner from another active component requires that path. Changing the target or converting to a fixed plane detaches only the old automatic face borders, retaining their currently evaluated geometry and entity IDs; unrelated projection links remain live. Pending positions on the involved target and author paths are captured with the edit, while unrelated pending positions remain pending. + +Paste New remaps internal certified targets and authoring paths to the copied definitions. Timeline paste reports retained external face targets and refuses translating such attachments until an explicit fixed-plane conversion establishes independent geometry. Rename preserves certified body identity; another producer using the same body name cannot repair a lost attachment. diff --git a/docs/parity.md b/docs/parity.md index 02a7a78e..c067fdfe 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,17 +2,17 @@ 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): 335 / 541 commands live (62%).** SOLID + SKETCH: 188 / 291 (65%). Registered commands: 311. -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: 630 / 1175 (54%). | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| | SOLID | CREATE | 23 | 35 | 66% | | SOLID | MODIFY | 19 | 32 | 59% | | SOLID | ASSEMBLE | 12 | 21 | 57% | -| SOLID | CONFIGURE | 2 | 3 | 67% | -| SOLID | CONSTRUCT | 18 | 20 | 90% | +| SOLID | CONFIGURE | 3 | 3 | 100% | +| SOLID | CONSTRUCT | 19 | 20 | 95% | | SOLID | INSPECT | 12 | 25 | 48% | | SOLID | INSERT | 6 | 11 | 55% | | SOLID | SELECT | 0 | 15 | 0% | @@ -20,7 +20,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SKETCH | CREATE | 41 | 41 | 100% | | SKETCH | MODIFY | 12 | 12 | 100% | | SKETCH | CONSTRAINTS | 15 | 15 | 100% | -| SKETCH | CONFIGURE | 2 | 3 | 67% | +| SKETCH | CONFIGURE | 3 | 3 | 100% | | SKETCH | INSPECT | 12 | 25 | 48% | | SKETCH | INSERT | 7 | 13 | 54% | | SKETCH | ASSEMBLE | 0 | 1 | 0% | @@ -29,8 +29,8 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SHEET METAL | CREATE | 15 | 19 | 79% | | 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 | CONFIGURE | 3 | 3 | 100% | +| SHEET METAL | CONSTRUCT | 19 | 20 | 95% | | SHEET METAL | INSPECT | 12 | 25 | 48% | | SHEET METAL | INSERT | 6 | 11 | 55% | | SHEET METAL | SELECT | 0 | 15 | 0% | @@ -42,8 +42,8 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | ASSEMBLY | MOTION | 3 | 3 | 100% | | ASSEMBLY | MODIFY | 8 | 11 | 73% | | ASSEMBLY | CONTACT | 2 | 2 | 100% | -| ASSEMBLY | CONFIGURE | 2 | 3 | 67% | -| ASSEMBLY | CONSTRUCT | 18 | 20 | 90% | +| ASSEMBLY | CONFIGURE | 3 | 3 | 100% | +| ASSEMBLY | CONSTRUCT | 19 | 20 | 95% | | ASSEMBLY | INSPECT | 12 | 25 | 48% | | ASSEMBLY | SELECT | 0 | 15 | 0% | | ASSEMBLY | POSITION | 2 | 2 | 100% | @@ -56,8 +56,8 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SURFACE | CREATE | 9 | 25 | 36% | | SURFACE | MODIFY | 17 | 26 | 65% | | SURFACE | ASSEMBLE | 11 | 20 | 55% | -| SURFACE | CONFIGURE | 2 | 3 | 67% | -| SURFACE | CONSTRUCT | 18 | 20 | 90% | +| SURFACE | CONFIGURE | 3 | 3 | 100% | +| SURFACE | CONSTRUCT | 19 | 20 | 95% | | SURFACE | INSPECT | 12 | 25 | 48% | | SURFACE | INSERT | 6 | 11 | 55% | | SURFACE | SELECT | 0 | 15 | 0% | @@ -66,8 +66,8 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | MESH | PREPARE | 0 | 4 | 0% | | MESH | MODIFY | 6 | 30 | 20% | | MESH | ASSEMBLE | 12 | 21 | 57% | -| MESH | CONFIGURE | 2 | 3 | 67% | -| MESH | CONSTRUCT | 18 | 20 | 90% | +| MESH | CONFIGURE | 3 | 3 | 100% | +| 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% | @@ -87,8 +87,8 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | PLASTIC | CREATE | 27 | 35 | 77% | | PLASTIC | MODIFY | 19 | 32 | 59% | | PLASTIC | ASSEMBLE | 11 | 20 | 55% | -| PLASTIC | CONFIGURE | 2 | 3 | 67% | -| PLASTIC | CONSTRUCT | 18 | 20 | 90% | +| PLASTIC | CONFIGURE | 3 | 3 | 100% | +| PLASTIC | CONSTRUCT | 19 | 20 | 95% | | PLASTIC | INSPECT | 12 | 26 | 46% | | PLASTIC | INSERT | 6 | 11 | 55% | | PLASTIC | SELECT | 0 | 15 | 0% | @@ -166,10 +166,12 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › ASSEMBLE › New Contact Set - SOLID › CONFIGURE › Configure - SOLID › CONFIGURE › Display Configuration Table +- SOLID › CONFIGURE › Create Configuration Rules - SOLID › CONSTRUCT › Offset Plane - 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 @@ -274,6 +276,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SKETCH › CONSTRAINTS › Polygon - SKETCH › CONFIGURE › Configure - SKETCH › CONFIGURE › Display Configuration Table +- SKETCH › CONFIGURE › Create Configuration Rules - SKETCH › INSPECT › Measure - SKETCH › INSPECT › Interference - SKETCH › INSPECT › Section Analysis @@ -331,9 +334,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › ASSEMBLE › Tangent Constraint - SOLID › ASSEMBLE › Joint Constraint - 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 @@ -368,7 +369,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › SELECT › Select Edge Priority - SOLID › SELECT › Select Component Priority - SOLID › SELECT › Selection Filters -- SKETCH › CONFIGURE › Create Configuration Rules - SKETCH › INSPECT › Validate - SKETCH › INSPECT › Component Connection Analysis - SKETCH › INSPECT › DOF Analysis diff --git a/examples/acceptance/sensor_enclosure.json b/examples/acceptance/sensor_enclosure.json index 12cef4ed..755dcf06 100644 --- a/examples/acceptance/sensor_enclosure.json +++ b/examples/acceptance/sensor_enclosure.json @@ -169,14 +169,9 @@ { "command": "sketch.create", "params": { - "plane": { - "face": [ - 5, - -7, - 2 - ] - }, - "name": "PCB standoff" + "plane": "XY", + "name": "PCB standoff", + "offset": "wall" } }, { @@ -283,13 +278,24 @@ "command": "sketch.create", "params": { "plane": { - "face": [ + "origin": [ 0, - -30, - 12 + 0, + 0 + ], + "x_dir": [ + 1, + 0, + 0 + ], + "y_dir": [ + 0, + 0, + 1 ] }, - "name": "USB" + "name": "USB", + "offset": "board_d / 2 + gap + wall" } }, { @@ -309,7 +315,7 @@ "command": "sketch.dimension", "params": { "entities": [ - "l5" + "l1" ], "value": "9 mm" } @@ -318,7 +324,7 @@ "command": "sketch.dimension", "params": { "entities": [ - "l6" + "l2" ], "value": "3.5 mm" } @@ -328,7 +334,7 @@ "params": { "entities": [ "origin", - "p9" + "p5" ], "type": "horizontal", "value": "usb_offset" @@ -339,7 +345,7 @@ "params": { "entities": [ "origin", - "p9" + "p5" ], "type": "vertical", "value": "wall + standoff_h + 1.6 mm + 1.75 mm" @@ -354,10 +360,10 @@ "params": { "profiles": [ [ - "l5", - "l6", - "l7", - "l8" + "l1", + "l2", + "l3", + "l4" ] ], "distance": "-(wall + 1 mm)", @@ -368,14 +374,9 @@ { "command": "sketch.create", "params": { - "plane": { - "face": [ - 5, - -7, - 2 - ] - }, - "name": "Lid boss" + "plane": "XY", + "name": "Lid boss", + "offset": "wall" } }, { @@ -591,13 +592,39 @@ "command": "sketch.create", "params": { "plane": { - "face": [ + "origin": [ 0, 0, - 25 + 0 + ], + "x_dir": [ + 1, + 0, + 0 + ], + "y_dir": [ + 0, + -1, + 0 ] }, - "name": "Lid lip" + "name": "Lid lip", + "offset": "-height" + } + }, + { + "command": "sketch.project", + "params": { + "refs": [ + { + "face": [ + 0, + 0, + 25 + ], + "body": "Lid" + } + ] } }, { @@ -672,14 +699,9 @@ { "command": "sketch.create", "params": { - "plane": { - "face": [ - 5, - -7, - 29 - ] - }, - "name": "Lid screw" + "plane": "XY", + "name": "Lid screw", + "offset": "height + lid_h" } }, { diff --git a/xtask/data/fusion-ids.tsv b/xtask/data/fusion-ids.tsv index 79b533d1..e61809ad 100644 --- a/xtask/data/fusion-ids.tsv +++ b/xtask/data/fusion-ids.tsv @@ -56,6 +56,7 @@ DisableAllContactCmd contact.disable_all ContactSetCmd contact.create FusionStartDesignConfigModeCmd config.configure FusionShowDesignConfigPanelCmd config.table +FusionConfigurationRulesCmd config.rules ConstructionPlaneOffsetFromPlaneCommand construct.plane.offset ConstructionPlaneAtAngleCommand construct.plane.at_angle ConstructionTangentPlaneCommand construct.plane.tangent