From d71bdb78ece41ac6ca618143aa786386656f00e2 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sun, 11 Oct 2026 00:37:26 -0400 Subject: [PATCH 1/5] feat: strengthen modeling, configuration, and assembly workflows Preserve linked sketch identities through deterministic face projection and resizing. Fix origin-corner picking, persistent inline navigation, appearance editing, configuration styles, component properties, contact sweeps, sheet exports, analytic STEP offsets, and bounded rolling-ball fit accuracy. Validated with full cargo xtask ci, native workflow screenshots, repeated enclosure replays, and analytic geometry regressions. --- ROADMAP.md | 43 ++- crates/doc/src/config.rs | 91 +++++- crates/doc/src/format.rs | 66 +++- crates/doc/src/params.rs | 6 +- crates/doc/src/project.rs | 5 +- crates/doc/src/project_order.rs | 290 +++++++++++++++++ crates/engine/src/cmd/browser.rs | 8 + crates/engine/src/cmd/component.rs | 9 + crates/engine/src/cmd/component_properties.rs | 142 +++++++++ .../src/cmd/component_properties_tests.rs | 129 ++++++++ crates/engine/src/cmd/config.rs | 189 ++++++++++- crates/engine/src/cmd/contact_sweep.rs | 251 +++++++++++++++ crates/engine/src/cmd/contact_sweep_tests.rs | 170 ++++++++++ crates/engine/src/cmd/edit.rs | 111 ++++++- crates/engine/src/cmd/edit_tests.rs | 268 ++++++++++++++++ crates/engine/src/cmd/motion.rs | 65 +++- crates/engine/src/cmd/motion_tests.rs | 34 ++ crates/engine/src/cmd/sheet.rs | 67 +++- crates/engine/src/cmd/sheet_tests.rs | 77 +++++ crates/engine/src/cmd/sketch_freeform.rs | 52 ++- crates/engine/src/cmd/sketch_modify.rs | 28 +- crates/engine/src/cmd/sketch_modify_tests.rs | 106 ++++++- crates/engine/src/frames.rs | 8 +- crates/engine/src/frames_tests.rs | 10 +- crates/kernel/src/curveblend.rs | 280 +++++++++++++--- crates/kernel/src/curveblend_tests.rs | 194 ++++++++++++ crates/kernel/src/step_in/geom.rs | 70 +++- crates/kernel/src/step_in/tests.rs | 100 ++++++ crates/sketch/src/link.rs | 172 ++++++++-- crates/sketch/src/link_identity_tests.rs | 158 +++++++++ crates/sketch/src/solver.rs | 49 ++- crates/ui-egui/src/context_menu.rs | 54 +++- crates/ui-egui/src/dialogs.rs | 13 +- crates/ui-egui/src/dialogs_appearance.rs | 299 +++++++++++++++--- crates/ui-egui/src/dialogs_motion.rs | 245 +++++++++++--- crates/ui-egui/src/dialogs_sheet.rs | 16 +- crates/ui-egui/src/dialogs_sheet_tests.rs | 43 +++ crates/ui-egui/src/menu_tests.rs | 46 ++- crates/ui-egui/src/sketch_corner_ui_tests.rs | 63 ++++ crates/ui-egui/src/sketch_dims.rs | 59 +++- crates/ui-egui/src/sketch_dims_tests.rs | 128 ++++++++ crates/ui-egui/src/sketch_tools.rs | 51 ++- crates/ui-egui/src/tools.rs | 6 +- .../scenarios/s08_sheet_metal_flat_dxf.json | 10 +- ...3_root_sketch_on_moved_component_face.json | 14 +- .../scenarios/s60_appearance_overrides.json | 17 + .../scenarios/s80_configuration_styles.json | 21 ++ docs/assembly-properties.md | 33 ++ docs/book/src/guide/assemblies.md | 9 + docs/book/src/guide/parameters.md | 6 +- docs/book/src/guide/sheet-metal.md | 17 +- docs/configurations.md | 43 +++ docs/contact-sets.md | 34 ++ docs/parity.md | 20 +- 54 files changed, 4179 insertions(+), 316 deletions(-) create mode 100644 crates/doc/src/project_order.rs create mode 100644 crates/engine/src/cmd/component_properties.rs create mode 100644 crates/engine/src/cmd/component_properties_tests.rs create mode 100644 crates/engine/src/cmd/contact_sweep.rs create mode 100644 crates/engine/src/cmd/contact_sweep_tests.rs create mode 100644 crates/engine/src/cmd/edit_tests.rs create mode 100644 crates/kernel/src/curveblend_tests.rs create mode 100644 crates/sketch/src/link_identity_tests.rs create mode 100644 crates/ui-egui/src/sketch_corner_ui_tests.rs create mode 100644 crates/ui-egui/src/sketch_dims_tests.rs create mode 100644 crates/ui-egui/tests/scenarios/s60_appearance_overrides.json create mode 100644 crates/ui-egui/tests/scenarios/s80_configuration_styles.json create mode 100644 docs/assembly-properties.md create mode 100644 docs/configurations.md create mode 100644 docs/contact-sets.md diff --git a/ROADMAP.md b/ROADMAP.md index 001165a6..bed9e2f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # SolveCraft roadmap -Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch → +Status as of **2026-10-11**. SolveCraft targets Fusion-style parametric modelling (sketch → features → parametric timeline) as an open, pure-Rust, agent-drivable application. ## At a glance @@ -8,7 +8,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat | Question | Answer | |---|---| | Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. | -| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) | +| Command parity (in scope) | **331 / 541 (61%)** in-scope commands; SOLID + SKETCH **186 / 291 (64%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity | | Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) | | Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green | @@ -48,18 +48,18 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M0 | Vertical slice | done | — | | M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 | | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | -| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 | +| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 | | M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 | -| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 | +| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 | | M8 | Surface workspace | deferred | — | -| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | -| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 | +| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | +| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | -| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 | -| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Left: configuration rules and component/face style columns | in progress | 4 | +| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/7"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | ## Known limitations @@ -89,7 +89,9 @@ headline counts in-scope tabs only and lists the deferred ones separately. that cut through curved faces (trimmed faces after booleans) are split onto the surface first. - STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass `cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units, - product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet, + product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres + and regular tori are read exactly (signed/nested distances, units and face sense). Freeform + offset surfaces and pcurve-only edges are not read yet, and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline bands) are split in two for meshing; measure() uses a finer tolerance for small radii. - Persistent naming (plan/adr/0002) names faces from their history and edges by their faces; @@ -97,12 +99,33 @@ headline counts in-scope tabs only and lists the deferred ones separately. face history (exact through coincident surfaces) is not wired in yet. - Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude and Revolve To Object on a curved face cut the tool back to the target's surface. -- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines. +- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees) + and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent + touch (no clean crossing) enter that profile as polylines. Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth). - Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not: filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two sections ruled. +- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when + its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to + the origin; clicked UI regressions verify geometry, closed profiles and undo. +- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches + existing geometry before assigning positions, retaining saved curve IDs and constraints + across enumeration changes and resize, including concentric circles. Ambiguous identities + retain the last geometry and report a lost reference; permutation matching is bounded. +- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes, + selecting visited values for replacement. Expression completion retains forward-Tab + priority; real keyboard regressions cover rectangles, lines and single-value circles. +- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface + distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two + scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry + check, not a new Fusion oracle result. Under-resolved fits return an error. +- Contact driving checks the trajectory between clear endpoints, including linked joints, + playback and sparse motion exports. Checks use bounded tessellation samples, not exact + continuous collision detection; local thin walls and small details remain limitations. + See [contact-set limits and recovery](docs/contact-sets.md). + ## Kernel truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs index 88c3941d..ffd465e4 100644 --- a/crates/doc/src/config.rs +++ b/crates/doc/src/config.rs @@ -1,6 +1,6 @@ //! Configurations: a table whose rows are variants of the design. Each column is a parameter -//! (its expression per row) or a feature (suppressed or not per row); activating a row sets -//! those values in the design, which then rebuilds as usual. +//! expression, feature suppression, body material or body appearance. Activating a row sets +//! those values in the design atomically, which then rebuilds as usual. use serde::{Deserialize, Serialize}; @@ -13,12 +13,16 @@ pub enum Column { Param(String), /// A feature's suppression (by feature id). Suppress(u64), + /// A body's physical material (library name, or Default to clear). + Material(String), + /// A body's appearance (library name, serialized Look, or empty to clear). + Appearance(String), } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ConfigRow { pub name: String, - /// One cell per column: an expression, or "true"/"false" for suppression. + /// One cell per column: expression, suppression, material name or appearance look. pub cells: Vec, } @@ -39,28 +43,69 @@ impl ConfigTable { } } +/// Parse an appearance cell. Empty/Default follows the body's material; named library looks +/// and JSON Look objects support both convenient editing and lossless custom colours. +pub fn appearance_cell(value: &str) -> Result> { + let value = value.trim(); + if value.is_empty() || value.eq_ignore_ascii_case("default") { + return Ok(None); + } + if value.len() > 4096 { + return Err(DocError::Invalid("appearance value too long".into())); + } + if let Some(look) = crate::appearance::library(value) { + return Ok(Some(look)); + } + let look: crate::appearance::Look = + serde_json::from_str(value).map_err(|_| DocError::Invalid(format!("unknown configuration appearance `{value}`")))?; + if look.name.chars().count() > 128 || !look.opacity.is_finite() || !(0.0..=1.0).contains(&look.opacity) { + return Err(DocError::Invalid("appearance requires a name up to 128 characters and opacity in 0..1".into())); + } + Ok(Some(look)) +} + +/// Parse a physical material cell, returning None for the default material. +pub fn material_cell(value: &str) -> Result> { + let name = crate::MATERIALS + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(value.trim())) + .map(|(n, _)| *n) + .ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?; + Ok((name != "Default").then(|| name.to_string())) +} + impl Document { /// A column's current value in the design. pub fn config_current(&self, c: &Column) -> Option { match c { Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e), Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()), + Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())), + Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()), } } - /// A column's display key: `param:` or `suppress:`. + /// A column's display key, prefixed by its kind to distinguish targets with equal names. pub fn config_key(&self, c: &Column) -> String { match c { Column::Param(n) => format!("param:{n}"), Column::Suppress(id) => format!("suppress:{}", self.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| id.to_string())), + Column::Material(body) => format!("material:{body}"), + Column::Appearance(body) => format!("appearance:{body}"), } } /// Set the design to a row's values (all or nothing). pub fn apply_configuration(&mut self, row: &str) -> Result<()> { let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?; + if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() { + return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into())); + } let mut next = self.clone(); for (c, v) in self.configs.columns.iter().zip(&r.cells) { + if v.len() > 4096 { + return Err(DocError::Invalid("configuration value too long".into())); + } match c { Column::Param(n) => { if next.config_current(c).as_deref() != Some(v.as_str()) { @@ -76,6 +121,22 @@ impl Document { let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?; f.suppressed = on; } + Column::Material(body) => match material_cell(v)? { + Some(material) => { + next.materials.insert(body.clone(), material); + } + None => { + next.materials.remove(body); + } + }, + Column::Appearance(body) => match appearance_cell(v)? { + Some(look) => { + next.appearances.bodies.insert(body.clone(), look); + } + None => { + next.appearances.bodies.remove(body); + } + }, } } next.configs.active = Some(row.to_string()); @@ -86,9 +147,14 @@ impl Document { /// The active row's values all hold in the design. pub fn configuration_matches(&self) -> bool { let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false }; + if r.cells.len() != self.configs.columns.len() { + return false; + } self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c { Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()), Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")), + Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()), + Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()), }) } } @@ -114,4 +180,21 @@ mod tests { assert!(d.apply_configuration("Bad").is_err()); assert_eq!(d, before, "all or nothing"); } + + #[test] + fn invalid_saved_appearance_rolls_back_material_and_parameters() { + let mut d = Document::new("A"); + d.set_param("width", "10 mm", None, None).unwrap(); + d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())]; + d.configs.rows.push(ConfigRow { + name: "Broken".into(), + cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()], + }); + let before = d.clone(); + assert!(d.apply_configuration("Broken").is_err()); + assert_eq!(d, before); + assert!(appearance_cell(r#"{"name":"Invalid","color":[1,2,3],"opacity":1e999}"#).is_err()); + assert!(appearance_cell(&"a".repeat(4097)).is_err()); + assert!(appearance_cell("default").unwrap().is_none()); + } } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 4b94a488..cf3cea21 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -20,13 +20,16 @@ //! "don't mate". Older joints keep their placement: their `flip` is inverted. //! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become //! explicit planes retaining their previous axes, including offset-plane expressions. +//! - 7: configuration tables can include body material and appearance columns. Version 6 +//! rows need no transformation; the version stamp lets older readers request an update +//! instead of encountering an unknown column variant. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 6; +pub const FORMAT: u32 = 7; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -39,7 +42,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6]; +const JSON_STEPS: [JsonStep; 6] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(())]; /// Old XZ references retain their exact frame, including nested offset and angled planes. /// New origin references use +Y; freezing the old frame avoids changing sketch coordinates, @@ -210,6 +213,65 @@ fn normalise(d: &mut Document) -> Result<()> { mod tests { use super::*; + #[test] + fn version_six_configuration_rows_upgrade_without_changing_values_or_features() { + use crate::config::{Column, ConfigRow}; + let mut d = Document::new("legacy configurations"); + d.set_param("width", "10 mm", None, None).unwrap(); + let feature = d + .add_feature( + crate::FeatureKind::Box { + corner: ["0".into(), "0".into(), "0".into()], + length: "width".into(), + width: "20".into(), + height: "5".into(), + operation: crate::Operation::NewBody, + }, + None, + ) + .unwrap(); + d.configs.columns = vec![Column::Param("width".into()), Column::Suppress(feature)]; + d.configs.rows = vec![ + ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] }, + ConfigRow { name: "Alternate".into(), cells: vec!["width / 2 + 5 mm".into(), "yes".into()] }, + ]; + d.configs.active = Some("Default".into()); + let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap(); + legacy["format"] = Value::from("solvecraft/6"); + let original_table = legacy["configs"].clone(); + let upgraded = read(&legacy.to_string()).unwrap(); + assert_eq!(upgraded, d, "the identity migration preserves the complete design"); + assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table); + assert_eq!(upgraded.format, "solvecraft/7"); + assert!(upgraded.configuration_matches()); + legacy.as_object_mut().unwrap().remove("configs"); + assert!(read(&legacy.to_string()).unwrap().configs.is_empty()); + legacy["configs"] = serde_json::json!({"columns":[],"rows":[]}); + assert!(read(&legacy.to_string()).unwrap().configs.is_empty()); + } + + #[test] + fn version_seven_styles_roundtrip_and_newer_versions_are_refused_before_columns() { + use crate::config::{Column, ConfigRow}; + let mut d = Document::new("configured styles"); + d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())]; + d.configs.rows = vec![ConfigRow { + name: "Blue steel".into(), + cells: vec!["Steel".into(), r#"{"name":"Custom blue","color":[40,90,186],"opacity":0.4}"#.into()], + }]; + d.apply_configuration("Blue steel").unwrap(); + let mut value: Value = serde_json::from_str(&d.to_json()).unwrap(); + assert_eq!(value["format"], "solvecraft/7"); + let roundtrip = read(&value.to_string()).unwrap(); + assert_eq!(roundtrip, d); + assert!(roundtrip.configuration_matches()); + value["format"] = Value::from(format_string(FORMAT + 1)); + value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]); + let error = read(&value.to_string()).unwrap_err().to_string(); + assert!(error.contains("newer SolveCraft") && error.contains("update SolveCraft"), "{error}"); + assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants"); + } + #[test] fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() { let mut d = Document::new("input repair"); diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 73e7c69a..e2b3d795 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -829,8 +829,10 @@ impl Document { } // Configuration cells refer to parameters too. for r in &mut self.configs.rows { - for cell in &mut r.cells { - *cell = expr::rename_reference(cell, old, new); + for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) { + if matches!(column, crate::config::Column::Param(_)) { + *cell = expr::rename_reference(cell, old, new); + } } } if let Some(c) = self.param_comments.remove(old) { diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 5378b1b8..677d72b1 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -20,6 +20,9 @@ const AXIS_HALF: f64 = 100.0; /// Points in one fitted chain at most (longer chains are thinned). const MAX_CHAIN: usize = 4000; +#[path = "project_order.rs"] +mod order; + /// A resolved link: its geometry in sketch coordinates and the (possibly moved) reference. #[derive(Clone, Debug)] pub struct Resolved { @@ -133,7 +136,7 @@ fn resolve_uncached( LinkKind::Intersect => section(plane, &mesh, Some(f)), _ => { let mut g = Vec::new(); - for e in mesh.face_edges(f) { + for e in order::face_edges(&mesh, f, plane)? { if mesh.seams.get(e).copied().unwrap_or(false) { continue; } diff --git a/crates/doc/src/project_order.rs b/crates/doc/src/project_order.rs new file mode 100644 index 00000000..70fe4834 --- /dev/null +++ b/crates/doc/src/project_order.rs @@ -0,0 +1,290 @@ +//! Stable face projection order: outer boundary first, then holes by geometry. +//! B-rep edge indices depend on face enumeration and must not choose sketch curve IDs. + +use std::collections::BTreeMap; + +use solvecraft_geom::{Mesh, Plane, Vec3}; + +use crate::{DocError, Result}; + +type Key = (i64, i64, i64); + +pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result> { + let bounds = mesh.bounds(); + let origin = bounds.min; + let local_origin = plane.to_local(origin); + let tol = (bounds.diagonal() * 1e-8).max(1e-9); + if !tol.is_finite() { + return Err(DocError::Invalid("face projection has invalid bounds".into())); + } + let key = |p: Vec3| -> Key { + let p = p - origin; + ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64) + }; + let mut edges: Vec = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect(); + if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) { + return Err(DocError::Invalid("face projection has an invalid boundary edge".into())); + } + if edges.len() > solvecraft_sketch::MAX_LINK_ENTITIES { + return Err(DocError::Invalid("face projection exceeds the boundary edge limit".into())); + } + let geometry_key = |e: usize| { + let pts = mesh.edges.get(e).map(Vec::as_slice).unwrap_or_default(); + let lo = pts.iter().copied().fold(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::min); + let hi = pts.iter().copied().fold(Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), Vec3::max); + (key(lo), key(hi)) + }; + edges.sort_by_key(|e| geometry_key(*e)); + let mut at: BTreeMap> = BTreeMap::new(); + for e in &edges { + if let Some(pts) = mesh.edges.get(*e) { + for point in [pts.first(), pts.last()].into_iter().flatten() { + at.entry(key(*point)).or_default().push(*e); + } + } + } + let mut used = vec![false; mesh.edges.len()]; + let mut boundaries = Vec::new(); + for start_edge in &edges { + if used.get(*start_edge).copied().unwrap_or(true) { + continue; + } + let Some(pts) = mesh.edges.get(*start_edge) else { continue }; + let (Some(a), Some(b)) = (pts.first(), pts.last()) else { continue }; + let start = if key(*a) <= key(*b) { *a } else { *b }; + let mut current = start; + let mut edge = *start_edge; + let mut boundary = Vec::new(); + let mut twice_area = 0.0; + for _ in 0..edges.len() { + let Some(pts) = mesh.edges.get(edge) else { break }; + let (Some(a), Some(b)) = (pts.first(), pts.last()) else { break }; + let forward = current.dist(*a) <= current.dist(*b); + for pair in pts.windows(2) { + let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue }; + let (a, b) = (plane.to_local(*a) - local_origin, plane.to_local(*b) - local_origin); + twice_area += if forward { a.cross(b) } else { b.cross(a) }; + } + current = if forward { *b } else { *a }; + if let Some(slot) = used.get_mut(edge) { + *slot = true; + } + boundary.push(edge); + if current.dist(start) <= tol { + break; + } + // Neighbouring quantization cells cover points on either side of a rounding + // boundary. Connectivity is checked in world space, not in the projection. + let (x, y, z) = key(current); + let mut candidates = Vec::new(); + for dx in -1..=1 { + for dy in -1..=1 { + for dz in -1..=1 { + let cell = (x.saturating_add(dx), y.saturating_add(dy), z.saturating_add(dz)); + if let Some(list) = at.get(&cell) { + candidates.extend(list.iter().copied().filter(|e| { + !used.get(*e).copied().unwrap_or(true) + && mesh.edges.get(*e).is_some_and(|p| { + p.first().is_some_and(|a| a.dist(current) <= tol) || p.last().is_some_and(|b| b.dist(current) <= tol) + }) + })); + } + } + } + } + candidates.sort_by_key(|e| geometry_key(*e)); + candidates.dedup(); + if candidates.len() > 1 { + return Err(DocError::Invalid("face projection has a branched boundary".into())); + } + let Some(next) = candidates.first() else { break }; + edge = *next; + } + boundary.sort_by_key(|e| geometry_key(*e)); + let closed = current.dist(start) <= tol; + if !closed || !twice_area.is_finite() { + return Err(DocError::Invalid("face projection has an open or invalid boundary".into())); + } + // Equal-sized holes use their geometric key, not round-off from summing the + // same area at different positions or walking their edges in reverse. + let area = (twice_area.abs() / (tol * bounds.diagonal().max(tol))).round() as i64; + let first = boundary.first().copied().map(geometry_key); + boundaries.push((area, first, boundary)); + } + boundaries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + Ok(boundaries.into_iter().flat_map(|(_, _, edges)| edges).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rectangles() -> Mesh { + let mut mesh = Mesh::default(); + for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] { + let pts = [Vec3::new(-x, -y, 0.0), Vec3::new(x, -y, 0.0), Vec3::new(x, y, 0.0), Vec3::new(-x, y, 0.0)]; + mesh.positions.extend(pts); + for i in 0..4 { + mesh.edges.push(vec![pts[i], pts[(i + 1) % 4]]); + mesh.edge_faces.push(vec![7]); + } + } + mesh + } + + #[test] + fn boundaries_and_edge_identity_are_independent_of_mesh_enumeration() { + let baseline = rectangles(); + let expected: Vec<_> = + face_edges(&baseline, 7, &Plane::XY).unwrap().into_iter().map(|e| super::super::fit_world(&Plane::XY, &baseline.edges[e])).collect(); + for shift in 0..baseline.edges.len() { + for reverse in [false, true] { + let mut mesh = baseline.clone(); + mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5) % baseline.edges.len()].clone()).collect(); + mesh.edges.rotate_left(shift); + if reverse { + mesh.edges.reverse(); + for pts in &mut mesh.edges { + pts.reverse(); + } + } + let actual: Vec<_> = face_edges(&mesh, 7, &Plane::XY) + .unwrap() + .into_iter() + .map(|e| { + let mut pts = mesh.edges[e].clone(); + // Lines' direction does not change their geometry. + if pts.first().unwrap().x > pts.last().unwrap().x || pts.first().unwrap().y > pts.last().unwrap().y { + pts.reverse(); + } + super::super::fit_world(&Plane::XY, &pts) + }) + .collect(); + let normalized: Vec<_> = expected + .iter() + .map(|g| { + let solvecraft_sketch::LinkGeom::Line(a, b) = g[0] else { panic!("line") }; + if a.x > b.x || a.y > b.y { vec![solvecraft_sketch::LinkGeom::Line(b, a)] } else { g.clone() } + }) + .collect(); + assert_eq!(actual, normalized); + let ordered = face_edges(&mesh, 7, &Plane::XY).unwrap(); + for (loop_index, chunk) in ordered.chunks(4).enumerate() { + let want = [40.0, 38.0, 4.0][loop_index]; + assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x.abs() <= want)); + assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).any(|p| p.x.abs() == want)); + } + } + } + } + + #[test] + fn rounded_boundary_arc_sweeps_survive_individual_edge_reversal() { + use solvecraft_geom::{Seg2, Vec2}; + use solvecraft_sketch::LinkGeom; + let mut mesh = Mesh::default(); + for (extent, radius) in [(10.0, 3.0), (8.0, 1.0)] { + for k in 0..4 { + let angle = k as f64 * std::f64::consts::FRAC_PI_2; + let rotate = |p: Vec2| (0..k).fold(p, |p, _| p.perp()); + let centre = rotate(Vec2::new(extent - radius, extent - radius)); + let arc = Seg2::Arc { center: centre, radius, start: angle, sweep: std::f64::consts::FRAC_PI_2 }; + let line = Seg2::Line { a: arc.point_at(1.0), b: rotate(Vec2::new(-(extent - radius), extent)) }; + for seg in [arc, line] { + let pts: Vec<_> = (0..=16).map(|i| Plane::XY.to_world(seg.point_at(i as f64 / 16.0))).collect(); + mesh.positions.extend(pts.iter().copied()); + mesh.edges.push(pts); + mesh.edge_faces.push(vec![2]); + } + } + } + let normalized = |mesh: &Mesh| -> Vec> { + face_edges(mesh, 2, &Plane::XY) + .unwrap() + .into_iter() + .map(|e| { + super::super::fit_world(&Plane::XY, &mesh.edges[e]) + .into_iter() + .map(|g| match g { + LinkGeom::Line(a, b) if (a.x, a.y) > (b.x, b.y) => LinkGeom::Line(b, a), + g => g, + }) + .collect() + }) + .collect() + }; + let expected = normalized(&mesh); + for shift in 0..mesh.edges.len() { + let mut permuted = mesh.clone(); + permuted.edges = (0..mesh.edges.len()).map(|i| mesh.edges[(i * 5 + shift) % mesh.edges.len()].clone()).collect(); + for (i, points) in permuted.edges.iter_mut().enumerate() { + if i % 2 == 0 { + points.reverse(); + } + } + let actual = normalized(&permuted); + for (a, b) in actual.iter().zip(&expected) { + assert_eq!(a.len(), b.len()); + for (a, b) in a.iter().zip(b) { + match (a, b) { + (LinkGeom::Line(a, b), LinkGeom::Line(c, d)) => assert!(a.dist(*c) < 1e-9 && b.dist(*d) < 1e-9), + (LinkGeom::Arc { c, a, b }, LinkGeom::Arc { c: cc, a: aa, b: bb }) => { + assert!(c.dist(*cc) < 1e-9 && a.dist(*aa) < 1e-9 && b.dist(*bb) < 1e-9); + } + _ => panic!("boundary changed kind"), + } + } + } + } + } + + #[test] + fn incomplete_and_branched_boundaries_are_reported_without_partial_geometry() { + let mut open = rectangles(); + open.edges.pop(); + open.edge_faces.pop(); + assert!(face_edges(&open, 7, &Plane::XY).unwrap_err().to_string().contains("open")); + let mut branched = rectangles(); + branched.edges.push(vec![Vec3::new(-40.0, -30.0, 0.0), Vec3::ZERO]); + branched.edge_faces.push(vec![7]); + assert!(face_edges(&branched, 7, &Plane::XY).is_err()); + let mut invalid = rectangles(); + invalid.edges[0].push(Vec3::new(f64::NAN, 0.0, 0.0)); + assert!(face_edges(&invalid, 7, &Plane::XY).unwrap_err().to_string().contains("invalid")); + } + + #[test] + fn equal_area_holes_use_geometric_order_after_translation() { + let mut baseline = rectangles(); + baseline.edges.truncate(4); + baseline.edge_faces.truncate(4); + for x in [-12.0, 12.0] { + let pts = [Vec3::new(x - 2.0, -2.0, 0.0), Vec3::new(x + 2.0, -2.0, 0.0), Vec3::new(x + 2.0, 2.0, 0.0), Vec3::new(x - 2.0, 2.0, 0.0)]; + for i in 0..4 { + baseline.edges.push(vec![pts[i], pts[(i + 1) % 4]]); + baseline.edge_faces.push(vec![7]); + } + } + let shift = Vec3::new(12345.6789, -23456.7891, 987.0); + for p in &mut baseline.positions { + *p += shift; + } + for points in &mut baseline.edges { + for p in points { + *p += shift; + } + } + for offset in 0..baseline.edges.len() { + let mut mesh = baseline.clone(); + mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5 + offset) % baseline.edges.len()].clone()).collect(); + for (i, points) in mesh.edges.iter_mut().enumerate() { + if i % 2 == 0 { + points.reverse(); + } + } + let order = face_edges(&mesh, 7, &Plane::XY).unwrap(); + assert!(order[4..8].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x < shift.x)); + assert!(order[8..12].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x > shift.x)); + } + } +} diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 1740ac04..3f906def 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -413,6 +413,14 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) { f.body = name.to_string(); } + for column in &mut d.configs.columns { + match column { + solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => { + *body = name.to_string(); + } + _ => {} + } + } if let Some(o) = d.body_offsets.remove(&old) { d.body_offsets.insert(name.to_string(), o); } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 62b8cc3a..193c896f 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -11,6 +11,9 @@ use super::CommandSpec; use crate::params::{bad, bool_, num, str_, string_list, vec3}; use crate::{Result, Session}; +#[path = "component_properties.rs"] +mod properties; + pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.create", "New Component", new_component) .at("SOLID", "ASSEMBLE") @@ -21,6 +24,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("bodies: [names] → one new component per body, named after it"), CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"), CommandSpec::new("component.list", "List Components", list).noundo(), + CommandSpec::new("component.properties", "Component Physical Properties", properties::component) + .icon("measure").noundo() + .params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"), + CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence) + .icon("measure").noundo() + .params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"), CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"), CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"), CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"), diff --git a/crates/engine/src/cmd/component_properties.rs b/crates/engine/src/cmd/component_properties.rs new file mode 100644 index 00000000..bef6410b --- /dev/null +++ b/crates/engine/src/cmd/component_properties.rs @@ -0,0 +1,142 @@ +//! Physical properties of an assembly placement, with original body material assignments. +use serde_json::{Value, json}; +use solvecraft_doc::{Document, Mat}; +use solvecraft_geom::Vec3; + +use crate::params::bad; +use crate::{Result, Session}; + +fn descendants(p: &Value, cmd: &str) -> Result { + match p.get("descendants") { + None => Ok(true), + Some(Value::Bool(v)) => Ok(*v), + Some(_) => Err(bad(cmd, "`descendants` must be a boolean")), + } +} + +fn placed_doc(s: &Session) -> Document { + let mut doc = (*s.doc).clone(); + for occurrence in &mut doc.occurrences { + if let Some(transform) = s.pending_moves.get(&occurrence.id) { + occurrence.transform = *transform; + } + } + doc +} + +/// A property total must cover the entire assembly. The display traversal intentionally +/// truncates hostile designs, so use an explicit error instead of accepting its prefix. +fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> { + let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)]; + let mut stack = vec![(0, Vec::::new(), solvecraft_doc::IDENTITY)]; + while let Some((component, path, transform)) = stack.pop() { + for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) { + if path.len() >= 64 || out.len() >= 100_000 { + return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)")); + } + let mut child_path = path.clone(); + child_path.push(occurrence.id); + let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform); + out.push((occurrence.component, child_path.clone(), world)); + stack.push((occurrence.component, child_path, world)); + } + } + Ok(out) +} + +pub(super) fn component(s: &mut Session, p: &Value) -> Result { + let cmd = "component.properties"; + let id = if p.get("component").is_some() { super::component(s, p, "component", cmd)? } else { s.active_component }; + let doc = placed_doc(s); + let path = doc.first_path(id); + let name = if id == 0 { + doc.name.clone() + } else { + doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))? + }; + let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?; + out["component"] = json!(id); + out["name"] = json!(name); + Ok(out) +} + +pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result { + let cmd = "occurrence.properties"; + let id = super::occurrence(s, p, cmd)?; + let doc = placed_doc(s); + let paths: Vec> = placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect(); + let path = if let Some(value) = p.get("path") { + let path = value + .as_array() + .filter(|a| !a.is_empty() && a.len() <= 64) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .ok_or_else(|| bad(cmd, "`path` must list 1…64 occurrence ids"))?; + if !paths.contains(&path) { + return Err(bad(cmd, "`path` does not identify a placement of this occurrence")); + } + path + } else { + match paths.as_slice() { + [path] => path.clone(), + [] => return Err(bad(cmd, "occurrence has no placement")), + _ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")), + } + }; + let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?; + out["occurrence"] = json!(id); + out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str())); + Ok(out) +} + +fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, cmd: &str) -> Result { + let state = s.model.state(); + let mut bodies = Vec::new(); + let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0); + let mut moment = Vec3::ZERO; + let (mut min, mut max) = + (Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)); + let (mut faces, mut edges, mut vertices) = (0, 0, 0); + let mut placement_count = 0; + for (component, path, transform) in + placements(doc, cmd)?.into_iter().filter(|(_, path, _)| if descendants { path.starts_with(selected) } else { path == selected }) + { + placement_count += 1; + for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) { + let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform); + let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?; + let measured = solvecraft_kernel::measure(&placed)?; + let density = doc.density(&body.name); + let body_mass = measured.volume * density / 1000.0; + volume += measured.volume; + area += measured.area; + mass += body_mass; + moment += measured.centroid * body_mass; + min = Vec3::new(min.x.min(measured.bbox.min.x), min.y.min(measured.bbox.min.y), min.z.min(measured.bbox.min.z)); + max = Vec3::new(max.x.max(measured.bbox.max.x), max.y.max(measured.bbox.max.y), max.z.max(measured.bbox.max.z)); + faces += measured.merged.faces; + edges += measured.merged.edges; + vertices += measured.merged.vertices; + bodies.push(json!({"name": body.name, "component": component, "path": path, + "material": doc.materials.get(&body.name).map(String::as_str).unwrap_or("Default"), + "density_g_cm3": density, "mass_g": body_mass, "volume_mm3": measured.volume, + "area_mm2": measured.area, "center_of_mass": measured.centroid, + "bbox": {"min": measured.bbox.min, "max": measured.bbox.max}})); + } + } + if !(volume.is_finite() && area.is_finite() && mass.is_finite() && moment.is_finite()) { + return Err(bad(cmd, "physical properties exceed supported numeric range")); + } + let centroid = if mass > 0.0 { Some(moment / mass) } else { None }; + if centroid.is_some_and(|point| !point.is_finite()) { + return Err(bad(cmd, "center of mass exceeds supported numeric range")); + } + let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": min, "max": max}) }; + Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants, + "placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies, + "total": {"volume_mm3": volume, "area_mm2": area, "mass_g": mass, + "center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}})) +} + +#[cfg(test)] +#[path = "component_properties_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/component_properties_tests.rs b/crates/engine/src/cmd/component_properties_tests.rs new file mode 100644 index 00000000..a9425c9d --- /dev/null +++ b/crates/engine/src/cmd/component_properties_tests.rs @@ -0,0 +1,129 @@ +use crate::Session; +use serde_json::{Value, json}; + +fn run(s: &mut Session, cmd: &str, params: Value) -> Value { + s.execute(cmd, ¶ms).unwrap() +} +fn close(actual: &Value, expected: f64) { + assert!((actual.as_f64().unwrap() - expected).abs() < 1e-6, "{actual} != {expected}"); +} +fn box_in(s: &mut Session, name: &str) -> (u64, u64, String) { + let c = run(s, "component.create", json!({"name": name})); + run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + let body = s.model.state().bodies.last().unwrap().name.clone(); + (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap(), body) +} + +#[test] +fn assembly_counts_instances_and_resolves_original_materials() { + let mut s = Session::default(); + let (component, occurrence, body) = box_in(&mut s, "Block"); + run(&mut s, "material.assign", json!({"bodies": [body], "material": "Steel"})); + let copy = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]})); + let rho = s.doc.density(&body); + let root = run(&mut s, "component.properties", json!({"component": "root"})); + assert_eq!(root["body_count"], 2); + close(&root["total"]["mass_g"], 2.0 * rho); + close(&root["total"]["volume_mm3"], 2000.0); + close(&root["total"]["center_of_mass"][0], 15.0); + let first = run(&mut s, "component.properties", json!({"component": component})); + assert_eq!(first["body_count"], 1); + close(&first["total"]["center_of_mass"][0], 5.0); + let second = run(&mut s, "occurrence.properties", json!({"occurrence": copy["occurrence"]})); + close(&second["total"]["center_of_mass"][0], 25.0); + assert_eq!(second["bodies"][0]["material"], "Steel"); + assert_eq!(first["path"], json!([occurrence])); +} + +#[test] +fn mass_centroid_is_density_weighted_and_pending_moves_are_read_only() { + let mut s = Session::default(); + let (a, oa, steel) = box_in(&mut s, "Steel block"); + run(&mut s, "material.assign", json!({"bodies": [steel], "material": "Steel"})); + run(&mut s, "component.activate", json!({"component": "root"})); + let (_, ob, plastic) = box_in(&mut s, "Plastic block"); + run(&mut s, "material.assign", json!({"bodies": [plastic], "material": "ABS Plastic"})); + run(&mut s, "occurrence.move", json!({"occurrence": ob, "translate": [30, 0, 0], "capture": false})); + let doc = s.doc.clone(); + let moves = s.pending_moves.clone(); + let r = run(&mut s, "component.properties", json!({"component": 0})); + let ra = s.doc.density(&steel); + let rb = s.doc.density(&plastic); + close(&r["total"]["center_of_mass"][0], (5.0 * ra + 35.0 * rb) / (ra + rb)); + close(&r["total"]["bbox"]["max"][0], 40.0); + assert!(std::sync::Arc::ptr_eq(&s.doc, &doc)); + assert_eq!(s.pending_moves, moves); + run(&mut s, "occurrence.move", json!({"occurrence": oa, "translate": [10, 0, 0], "axis": [0, 0, 1], "angle": "90 deg"})); + let r = run(&mut s, "component.properties", json!({"component": a})); + close(&r["total"]["center_of_mass"][0], 5.0); + close(&r["total"]["center_of_mass"][1], 5.0); +} + +#[test] +fn nested_instances_require_a_path_and_can_exclude_descendants() { + let mut s = Session::default(); + let parent = run(&mut s, "component.create", json!({"name": "Assembly"})); + let (_, child_occurrence, _) = box_in(&mut s, "Child"); + let copy = run(&mut s, "occurrence.copy", json!({"component": parent["component"], "translate": [100, 0, 0]})); + assert!(s.execute("occurrence.properties", &json!({"occurrence": child_occurrence})).is_err()); + let r = run(&mut s, "occurrence.properties", json!({"occurrence": child_occurrence, "path": [copy["occurrence"], child_occurrence]})); + close(&r["total"]["center_of_mass"][0], 105.0); + let r = run(&mut s, "component.properties", json!({"component": parent["component"]})); + assert_eq!(r["body_count"], 1); + let r = run(&mut s, "component.properties", json!({"component": parent["component"], "descendants": false})); + assert_eq!(r["body_count"], 0); + assert!(r["total"]["center_of_mass"].is_null()); + assert!(r["total"]["bbox"].is_null()); +} + +#[test] +fn body_reassignment_offsets_preserve_world_properties() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + let body = s.model.state().bodies.first().unwrap().name.clone(); + let c = run(&mut s, "component.create", json!({"name": "Moved"})); + run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "translate": [100, 0, 0]})); + run(&mut s, "component.move_bodies", json!({"bodies": [body], "component": c["component"]})); + let r = run(&mut s, "component.properties", json!({"component": c["component"]})); + close(&r["total"]["center_of_mass"][0], 5.0); +} + +#[test] +fn properties_reject_hostile_identifiers_and_options() { + let mut s = Session::default(); + let (_, occurrence, _) = box_in(&mut s, "Block"); + for p in [json!({"component": []}), json!({"component": "missing"}), json!({"descendants": 1})] { + assert!(s.execute("component.properties", &p).is_err()); + } + for path in [json!([]), json!(["bad"]), json!([0]), json!(vec![occurrence; 65])] { + assert!(s.execute("occurrence.properties", &json!({"occurrence": occurrence, "path": path})).is_err()); + } +} + +#[test] +fn compact_exponentially_instanced_assembly_refuses_partial_totals() { + let mut s = Session::default(); + let mut last_occurrence = 0; + // Only 17 component definitions and 34 occurrences, but over 100000 placements. + for _ in 0..17 { + let parent = s.active_component; + let component = s.doc_mut().add_component(None, parent).unwrap(); + last_occurrence = s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap(); + s.active_component = component; + } + for command in ["component.properties", "occurrence.properties"] { + let error = s.execute(command, &json!({"component": 0, "occurrence": last_occurrence})).unwrap_err(); + assert!(error.to_string().contains("traversal limits"), "{error}"); + } +} + +#[test] +fn excessively_nested_assembly_refuses_partial_totals() { + let mut s = Session::default(); + for _ in 0..65 { + let parent = s.active_component; + s.active_component = s.doc_mut().add_component(None, parent).unwrap(); + } + let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err(); + assert!(error.to_string().contains("traversal limits"), "{error}"); +} diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index 74f2c8dc..334e2bf8 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -10,21 +10,33 @@ use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params( - "params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)", + "params?: [parameter names]; features?: [feature names]; materials?: [body names]; appearances?: [body names] (columns to add; with no rows yet, a \"Default\" row takes the current values)", ), CommandSpec::new("config.table", "Display Configuration Table", show) .at("SOLID", "CONFIGURE") .icon("params") .noundo() .params("→ columns (param: or suppress:), rows, the active row and whether the design still matches it"), - CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? (name); delete?: bool"), + CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? | material? | appearance? (target name); delete?: bool"), CommandSpec::new("config.row", "Configuration Row", row).params( - "name; from? (row to copy; default the current values); values?: {param name or feature name: expression or true/false}; rename?; delete?: bool", + "name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool", ), CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"), ]; fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { + if ["param", "feature", "material", "appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 { + return Err(bad(cmd, "give exactly one of `param`, `feature`, `material`, or `appearance`")); + } + for (key, appearance) in [("material", false), ("appearance", true)] { + if let Some(body) = str_(p, key) { + let c = if appearance { Column::Appearance(body.to_string()) } else { Column::Material(body.to_string()) }; + if !s.doc.configs.columns.contains(&c) && s.model.state().body(body).is_none() { + return Err(bad(cmd, format!("no body `{body}`"))); + } + return Ok(c); + } + } if let Some(n) = str_(p, "param") { if !s.doc.all_param_exprs().iter().any(|(m, _, _)| m == n) { return Err(bad(cmd, format!("no parameter `{n}`"))); @@ -63,6 +75,13 @@ fn configure(s: &mut Session, p: &Value) -> Result { let c = column_of(s, &json!({ "feature": n }), cmd)?; add_column(s, c, cmd)?; } + for key in ["materials", "appearances"] { + for n in string_list(p, key) { + let field = if key == "materials" { "material" } else { "appearance" }; + let c = column_of(s, &json!({ field: n }), cmd)?; + add_column(s, c, cmd)?; + } + } if s.doc.configs.rows.is_empty() { let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect(); let t = &mut s.doc_mut().configs; @@ -122,32 +141,54 @@ fn row(s: &mut Session, p: &Value) -> Result { if let Some(vals) = p.get("values") { let vals = vals.as_object().ok_or_else(|| bad(cmd, "`values` must map columns to values"))?; for (k, v) in vals { - let text = match v { + let mut text = match v { Value::String(t) => t.clone(), Value::Bool(b) => b.to_string(), Value::Number(n) => n.to_string(), + Value::Object(_) if k.starts_with("appearance:") => serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?, _ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))), }; if text.len() > 4096 { return Err(bad(cmd, "value too long")); } - let key = k.strip_prefix("param:").or_else(|| k.strip_prefix("suppress:")).unwrap_or(k); let col = s .doc .configs .columns .iter() - .position(|c| match c { - Column::Param(n) => n == key, - Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == key), + .position(|c| { + s.doc.config_key(c) == *k + || match c { + Column::Param(n) => n == k, + Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == *k), + Column::Material(_) | Column::Appearance(_) => false, + } }) .ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?; + match s.doc.configs.columns.get(col) { + Some(Column::Material(_)) => { + text = solvecraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into()); + } + Some(Column::Appearance(_)) => { + text = match solvecraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? { + Some(look) => serde_json::to_string(&look).map_err(|e| bad(cmd, e.to_string()))?, + None => String::new(), + }; + } + _ => {} + } if let Some(cell) = cells.get_mut(col) { *cell = text; } } } let rename = str_(p, "rename").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).map(str::to_string); + if p.get("rename").is_some() && (rename.is_none() || idx.is_none()) { + return Err(bad(cmd, "rename requires an existing row and a nonempty name up to 128 bytes")); + } + if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) { + return Err(bad(cmd, "configuration name already exists")); + } let t = &mut s.doc_mut().configs; match idx { Some(i) => { @@ -236,4 +277,136 @@ mod tests { run(&mut s, "config.column", json!({"feature": fillet, "delete": true})); assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1)); } + + #[test] + fn configuration_materials_and_looks_are_undoable_and_persistent() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + let body = s.model.state().bodies.first().unwrap().name.clone(); + run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]})); + let material = format!("material:{body}"); + let appearance = format!("appearance:{body}"); + run(&mut s, "config.row", json!({"name": "Metal", "values": {material.clone(): "steel", appearance.clone(): "ABS - Red"}})); + run( + &mut s, + "config.row", + json!({"name": "Custom", "from": "Metal", "values": { + material.clone(): "Aluminum", appearance.clone(): {"name": "Ocean", "color": [1, 2, 3], "opacity": 0.4} + }}), + ); + run(&mut s, "config.activate", json!({"row": "Metal"})); + assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel")); + assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().color, [196, 40, 40]); + let mass = run(&mut s, "inspect.measure", json!({})); + assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 7.85).abs() < 1e-6); + assert_eq!(run(&mut s, "config.table", json!({}))["matches"], true); + run(&mut s, "config.activate", json!({"row": "Custom"})); + let mass = run(&mut s, "inspect.measure", json!({})); + assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 2.7).abs() < 1e-6); + assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().opacity, 0.4); + let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(reopened, *s.doc); + assert!(reopened.configuration_matches()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel")); + run(&mut s, "config.activate", json!({"row": "Default"})); + assert!(!s.doc.materials.contains_key(&body)); + assert!(!s.doc.appearances.bodies.contains_key(&body)); + run(&mut s, "config.activate", json!({"row": "Metal"})); + run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": "ABS - Blue"})); + assert!(!s.doc.configuration_matches()); + run(&mut s, "config.column", json!({"appearance": body, "delete": true})); + assert_eq!(s.doc.configs.columns.len(), 1); + assert!(s.doc.configs.rows.iter().all(|r| r.cells.len() == 1)); + } + + #[test] + fn configuration_styles_reject_hostile_values_without_changes() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + let body = s.model.state().bodies.first().unwrap().name.clone(); + run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]})); + let material = format!("material:{body}"); + let appearance = format!("appearance:{body}"); + for params in [ + json!({"name": "Bad", "values": {material: "Unobtainium"}}), + json!({"name": "Bad", "values": {appearance.clone(): "#oops"}}), + json!({"name": "Bad", "values": {appearance.clone(): {"name": "Invalid", "color": [1, 2, 300], "opacity": 1}}}), + json!({"name": "Bad", "values": {appearance: {"name": "Invalid", "color": [1, 2, 3], "opacity": -1}}}), + json!({"name": "Default", "rename": ""}), + ] { + let before = s.doc.clone(); + assert!(s.execute("config.row", ¶ms).is_err(), "{params}"); + assert_eq!(s.doc, before); + } + assert!(s.execute("config.column", &json!({"appearance": "missing"})).is_err()); + assert!(s.execute("config.column", &json!({"appearance": body, "material": body})).is_err()); + run(&mut s, "config.row", json!({"name": "Other"})); + assert!(s.execute("config.row", &json!({"name": "Other", "rename": "Default"})).is_err()); + // A malformed persisted row must fail atomically rather than silently apply its prefix. + s.doc_mut().configs.rows.first_mut().unwrap().cells.pop(); + let before = s.doc.clone(); + assert!(s.execute("config.activate", &json!({"row": "Default"})).is_err()); + assert_eq!(s.doc, before); + } + + #[test] + fn configuration_captures_unicode_custom_appearance_names() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + let body = s.model.state().bodies.first().unwrap().name.clone(); + let name = "é".repeat(128); + run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": name, "color": [40, 90, 186]})); + run(&mut s, "config.configure", json!({"appearances": [body]})); + run(&mut s, "appearance.assign", json!({"bodies": [body]})); + run(&mut s, "config.activate", json!({"row": "Default"})); + assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().name, name); + assert!(s.doc.configuration_matches()); + let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert!(reopened.configuration_matches()); + } + + #[test] + fn configuration_styles_survive_parameter_and_body_renames() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name": "Steel", "expression": "10 mm"})); + run(&mut s, "parameters.change", json!({"name": "double", "expression": "Steel * 2"})); + run(&mut s, "solid.box", json!({"length": "double", "width": 10, "height": 10, "body_name": "Block"})); + run(&mut s, "config.configure", json!({"params": ["Steel", "double"], "materials": ["Block"], "appearances": ["Block"]})); + run( + &mut s, + "config.row", + json!({"name": "Styled", "values": { + "param:Steel": "20 mm", "param:double": "Steel * 3", "material:Block": "Steel", "appearance:Block": "Steel - Satin" + }}), + ); + run(&mut s, "config.activate", json!({"row": "Styled"})); + let styles: Vec> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect(); + run(&mut s, "parameters.rename", json!({"name": "Steel", "new_name": "gauge"})); + let after: Vec> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect(); + assert_eq!(after, styles, "renaming an expression reference must preserve style cells verbatim"); + let table = run(&mut s, "config.table", json!({})); + assert_eq!(table["columns"][0], "param:gauge"); + assert_eq!(table["rows"][1]["values"]["param:double"], "gauge * 3"); + assert!(s.doc.configuration_matches()); + run(&mut s, "body.rename", json!({"body": "Block", "name": "Housing"})); + let table = run(&mut s, "config.table", json!({})); + assert_eq!(table["columns"][2], "material:Housing"); + assert_eq!(table["columns"][3], "appearance:Housing"); + assert!(s.doc.configuration_matches()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(run(&mut s, "config.table", json!({}))["columns"][2], "material:Block"); + run(&mut s, "edit.redo", json!({})); + let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap()); + run(&mut reopened, "config.activate", json!({"row": "Default"})); + assert!(!reopened.doc.materials.contains_key("Housing")); + assert!(!reopened.doc.appearances.bodies.contains_key("Housing")); + run(&mut reopened, "config.activate", json!({"row": "Styled"})); + assert_eq!(reopened.doc.materials.get("Housing").map(String::as_str), Some("Steel")); + assert_eq!(reopened.doc.appearances.bodies.get("Housing").unwrap().name, "Steel - Satin"); + assert!(!reopened.doc.materials.contains_key("Block")); + assert!(!reopened.doc.appearances.bodies.contains_key("Block")); + assert!((volume(&mut reopened) - 6000.0).abs() < 1e-6); + assert!(reopened.doc.configuration_matches()); + } } diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs new file mode 100644 index 00000000..45fc941c --- /dev/null +++ b/crates/engine/src/cmd/contact_sweep.rs @@ -0,0 +1,251 @@ +//! Bounded, chronological checks of a joint trajectory. This samples tessellated geometry; +//! it is deliberately not advertised as exact continuous collision detection. +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; + +use solvecraft_doc::joints; +use solvecraft_doc::{IDENTITY, Mat, apply_point, mat_mul}; +use solvecraft_geom::{Aabb3, Mesh, Vec3}; + +use crate::params::bad; +use crate::{Result, Session}; + +const MAX_SAMPLES: usize = 4096; +const MAX_PLACEMENTS: usize = 4096; +const MAX_BODIES: usize = 256; +const MAX_TRIANGLES: usize = 50_000; +const MAX_WORK: usize = 50_000_000; + +fn limit(message: &str) -> crate::EngineError { + bad("contact.sweep", message) +} + +struct CachedBody { + path: Vec, + offset: Mat, + mesh: Arc, + size: f64, +} + +pub(super) struct ContactGeometry { + bodies: Vec, + pairs: Vec<(usize, usize, String, String)>, + step: f64, +} + +impl ContactGeometry { + pub(super) fn new(s: &Session) -> Result { + let assembly = &s.doc.assembly; + let sets: Vec> = if assembly.contact_all { + vec![s.doc.occurrences.iter().map(|o| o.id).collect()] + } else { + assembly.contact_sets.iter().filter(|set| !set.suppressed).map(|set| set.occurrences.clone()).collect() + }; + let mut out = Self { bodies: Vec::new(), pairs: Vec::new(), step: f64::INFINITY }; + if sets.is_empty() { + return Ok(out); + } + let state = s.model.state(); + let mut meshes: HashMap> = HashMap::new(); + let mut stack = vec![(0, Vec::::new())]; + let mut count = 0; + while let Some((component, path)) = stack.pop() { + count += 1; + if count > MAX_PLACEMENTS || path.len() > 64 { + return Err(limit("assembly exceeds contact sweep placement budget")); + } + for occurrence in s.doc.occurrences.iter().filter(|o| o.parent == component) { + let mut child = path.clone(); + child.push(occurrence.id); + stack.push((occurrence.component, child)); + if stack.len() > MAX_PLACEMENTS { + return Err(limit("assembly exceeds contact sweep placement budget")); + } + } + if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) { + continue; + } + for body in state.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == component) { + if body.body.is_mesh() { + return Err(limit("contact sweep does not support mesh bodies; disable their contact set to move them")); + } + if out.bodies.len() >= MAX_BODIES { + return Err(limit("too many bodies for a contact sweep")); + } + let mesh = if let Some(mesh) = meshes.get(&body.name) { + mesh.clone() + } else { + let mesh = body.body.tessellate((body.body.size() * 1e-3).max(1e-3))?; + if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len().saturating_add(mesh.triangles.len().saturating_mul(4)) > 200_000 { + return Err(limit("body exceeds contact sweep tessellation budget")); + } + let extent = mesh.bounds().size(); + // Whole-body widths along bounded distinct facet normals catch tilted + // plates without interpreting skinny mesh triangles as thin material. + // Local thin walls of a wider body remain a sampling limitation. + let mut thickness = extent.x.min(extent.y).min(extent.z); + let mut directions: Vec = Vec::new(); + for triangle in &mesh.triangles { + let Some([a, b, c]) = mesh.tri(triangle) else { + return Err(limit("invalid contact tessellation")); + }; + let Some(direction) = (b - a).cross(c - a).normalized() else { continue }; + if directions.len() < 32 && !directions.iter().any(|d| d.dot(direction).abs() > 1.0 - 1e-6) { + directions.push(direction); + } + } + for direction in directions { + let (mut low, mut high) = (f64::INFINITY, f64::NEG_INFINITY); + for point in &mesh.positions { + let projected = point.dot(direction); + low = low.min(projected); + high = high.max(projected); + } + thickness = thickness.min(high - low); + } + if !thickness.is_finite() || thickness <= 1e-9 { + return Err(limit("body is too thin for a bounded contact sweep")); + } + out.step = out.step.min(thickness * 0.25); + let mesh = Arc::new(mesh); + meshes.insert(body.name.clone(), mesh.clone()); + mesh + }; + out.bodies.push(CachedBody { + path: path.clone(), + offset: s.doc.body_offsets.get(&body.name).copied().unwrap_or(IDENTITY), + mesh, + size: body.body.size(), + }); + } + } + let mut pairs = BTreeSet::new(); + for set in &sets { + let tagged: Vec<(usize, u64)> = + out.bodies.iter().enumerate().filter_map(|(i, b)| b.path.iter().rev().find(|id| set.contains(id)).map(|id| (i, *id))).collect(); + for (i, (a, oa)) in tagged.iter().enumerate() { + for (b, ob) in tagged.iter().skip(i + 1) { + if oa != ob { + pairs.insert((*a, *b, *oa, *ob)); + } + } + } + } + let name = |id| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default(); + out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, name(oa), name(ob))).collect(); + Ok(out) + } + + pub(super) fn has_pairs(&self) -> bool { + !self.pairs.is_empty() + } + + fn frame(body: &CachedBody, s: &Session) -> Result { + let mut frame = IDENTITY; + for id in &body.path { + let occurrence = s.doc.occurrences.iter().find(|o| o.id == *id).ok_or_else(|| limit("contact occurrence disappeared"))?; + frame = mat_mul(&frame, &occurrence.transform); + } + Ok(mat_mul(&frame, &body.offset)) + } + + fn bounds(&self, s: &Session) -> Result { + let mut bounds = Aabb3::EMPTY; + for body in &self.bodies { + let frame = Self::frame(body, s)?; + for point in &body.mesh.positions { + bounds.add(apply_point(&frame, *point)); + } + } + Ok(bounds) + } + + pub(super) fn samples(&self, start: &Session, end: &Session) -> Result { + let a = joints::joint_values(&start.doc.assembly); + let b = joints::joint_values(&end.doc.assembly); + let bounds = self.bounds(start)?.union(&self.bounds(end)?); + let state = start.model.state(); + let (mut linear, mut angular, mut radius) = (0.0, 0.0, 0.0_f64); + for joint in start.doc.assembly.joints.iter().filter(|j| !j.suppressed) { + for (index, _) in joint.kind.dofs().iter().enumerate() { + let delta = (a.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0) + - b.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0)) + .abs(); + if joint.kind.is_angle(index) { + angular += delta; + if delta == 0.0 { + continue; + } + for session in [start, end] { + for origin in [&joint.a, &joint.b] { + let frame = session.doc.origin_world(&state, origin)?; + let pivot = apply_point(&frame, Vec3::ZERO); + let far = Vec3::new( + (bounds.min.x - pivot.x).abs().max((bounds.max.x - pivot.x).abs()), + (bounds.min.y - pivot.y).abs().max((bounds.max.y - pivot.y).abs()), + (bounds.min.z - pivot.z).abs().max((bounds.max.z - pivot.z).abs()), + ); + radius = radius.max(far.len()); + } + } + } else { + linear += delta; + } + } + } + let required = ((linear + angular * (radius + linear)) / self.step).max(angular / 1_f64.to_radians()).ceil().max(1.0); + if !required.is_finite() || required > MAX_SAMPLES as f64 { + return Err(limit("motion exceeds contact sweep sampling budget; drive the joint in smaller increments")); + } + let vertices: usize = self.bodies.iter().map(|body| body.mesh.positions.len()).sum(); + if (required as usize).saturating_mul(vertices.saturating_add(self.pairs.len())) > MAX_WORK { + return Err(limit("motion exceeds contact sweep transform budget; simplify the contact set or use smaller increments")); + } + Ok(required as usize) + } + + pub(super) fn first_hit(&self, s: &Session, work: &mut usize) -> Result> { + let meshes: Vec = self + .bodies + .iter() + .map(|body| { + let frame = Self::frame(body, s)?; + let positions = body.mesh.positions.iter().map(|p| apply_point(&frame, *p)).collect(); + Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() }) + }) + .collect::>()?; + for (a, b, aname, bname) in &self.pairs { + let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else { + return Err(limit("invalid contact geometry pair")); + }; + let (aabb, bbb) = (ma.bounds(), mb.bounds()); + if aabb.max.x < bbb.min.x + || bbb.max.x < aabb.min.x + || aabb.max.y < bbb.min.y + || bbb.max.y < aabb.min.y + || aabb.max.z < bbb.min.z + || bbb.max.z < aabb.min.z + { + continue; + } + let cost = ma + .positions + .len() + .saturating_add(ma.triangles.len().saturating_mul(4)) + .saturating_mul(mb.triangles.len()) + .saturating_add(mb.positions.len().saturating_add(mb.triangles.len().saturating_mul(4)).saturating_mul(ma.triangles.len())); + *work = work.saturating_add(cost); + if *work > MAX_WORK { + return Err(limit("motion exceeds contact sweep collision budget; simplify the contact set or use smaller increments")); + } + if let Some(depth) = super::mesh_overlap(ma, mb, 1e-4 * ba.size.max(bb.size).max(1.0)) { + return Ok(Some((aname.clone(), bname.clone(), depth))); + } + } + Ok(None) + } +} + +#[cfg(test)] +#[path = "contact_sweep_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/contact_sweep_tests.rs b/crates/engine/src/cmd/contact_sweep_tests.rs new file mode 100644 index 00000000..6e7b6baf --- /dev/null +++ b/crates/engine/src/cmd/contact_sweep_tests.rs @@ -0,0 +1,170 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; + +fn run(s: &mut Session, command: &str, p: Value) -> Value { + s.execute(command, &p).unwrap() +} +fn occurrence(s: &Session, name: &str) -> u64 { + s.doc.occurrence_of(s.doc.find_component(name).unwrap()).unwrap().id +} +fn slider() -> Session { + let mut s = Session::default(); + for (name, x) in [("A", 0), ("B", 20)] { + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})); + } + let a = occurrence(&s, "A"); + run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true})); + run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"})); + run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10})); + run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]})); + s +} +fn rail_value(s: &Session) -> f64 { + s.doc.assembly.joints.iter().find(|j| j.name == "Rail").unwrap().values[0] +} + +#[test] +fn slider_cannot_tunnel_to_a_collision_free_endpoint() { + let mut s = slider(); + let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -30})); + assert!(result["stopped_by_contact"].is_object(), "{result}"); + assert!(rail_value(&s).abs() < 2e-3, "{}", rail_value(&s)); + run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10})); + assert!((rail_value(&s) - 10.0).abs() < 1e-6, "can move away from contact"); + run(&mut s, "contact.disable_all", json!({})); + run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -100000})); + assert!((rail_value(&s) + 100000.0).abs() < 1e-6, "disabled contact skips sweep budget"); +} + +#[test] +fn revolute_cannot_cross_a_blocker_between_clear_endpoints() { + let mut s = Session::default(); + for (name, corner) in [("Arm", [20, -2, -2]), ("Blocker", [-2, 20, -2])] { + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": corner})); + } + run( + &mut s, + "joint.create", + json!({"type": "revolute", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Arm", "point": [0,0,0]}, "flip": true, "name": "Pivot"}), + ); + run(&mut s, "contact.create", json!({"occurrences": ["Arm", "Blocker"]})); + let result = run(&mut s, "joint.drive", json!({"joint": "Pivot", "value": "180 deg"})); + assert!(result["stopped_by_contact"].is_object(), "{result}"); + let angle = s.doc.assembly.joints.iter().find(|j| j.name == "Pivot").unwrap().values[0]; + assert!(angle > 60_f64.to_radians() && angle < 90_f64.to_radians(), "angle={angle}"); + let arm = solvecraft_doc::apply_point(&solvecraft_doc::joints::occurrence_world(&s.doc, occurrence(&s, "Arm")), Vec3::new(22.0, 0.0, 0.0)); + assert!(arm.y > 20.0 && arm.x > 2.0, "{arm:?}"); +} + +#[test] +fn linked_slider_uses_effective_travel_for_contact() { + let mut s = slider(); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "component.create", json!({"name": "Input"})); + run( + &mut s, + "joint.create", + json!({"type": "slider", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Input", "point": [0,0,0]}, "flip": true, "name": "Input Joint"}), + ); + run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": 1})); + run(&mut s, "joint.motion_link", json!({"a": "Input Joint", "b": "Rail", "ratio": 10})); + let result = run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": -3})); + assert!(result["stopped_by_contact"].is_object(), "{result}"); + let input = s.doc.assembly.joints.iter().find(|j| j.name == "Input Joint").unwrap().values[0]; + assert!(input.abs() < 2e-4, "{input}"); +} + +#[test] +fn motion_play_and_sparse_export_share_swept_contact_checks() { + let mut s = slider(); + run(&mut s, "motion.study", json!({"name": "Crossing", "steps": 100, "keys": [{"joint": "Rail", "points": [[0,10], [100,-30]]}]})); + let doc = s.doc.clone(); + let exported = run(&mut s, "motion.export", json!({"study": "Crossing", "samples": 2})); + assert!(std::sync::Arc::ptr_eq(&doc, &s.doc), "export leaves document untouched"); + assert!(exported["samples"][1]["stopped_by_contact"].is_object(), "{exported}"); + let played = run(&mut s, "motion.play", json!({"study": "Crossing", "step": 100})); + assert!(played["stopped_by_contact"].is_object(), "{played}"); + assert!(rail_value(&s).abs() < 2e-3); +} + +#[test] +fn over_budget_sweep_errors_and_restores_document() { + let mut s = slider(); + let doc = s.doc.clone(); + let error = s.execute("joint.drive", &json!({"joint": "Rail", "value": -100000})).unwrap_err(); + assert!(error.to_string().contains("sampling budget"), "{error}"); + assert_eq!(*s.doc, *doc); + assert!((rail_value(&s) - 10.0).abs() < 1e-6); +} + +#[test] +fn thin_tilted_plates_cannot_cross_between_axis_bound_samples() { + let mut s = Session::default(); + let normal = solvecraft_doc::apply_vector(&solvecraft_doc::rigid(Vec3::ZERO, Vec3::ZERO, Vec3::new(1.0, 1.0, 0.0), 55_f64.to_radians()), Vec3::Z); + for name in ["Plate A", "Plate B"] { + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 0.05, "corner": [-5,-5,-0.025], "body_name": name})); + run(&mut s, "solid.move", json!({"bodies": [name], "axis": [1,1,0], "angle": "55 deg"})); + } + let (a, b) = (occurrence(&s, "Plate A"), occurrence(&s, "Plate B")); + run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": normal * 2.0})); + run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true})); + run( + &mut s, + "joint.as_built", + json!({"type": "slider", "a": "Plate A", "b": "Plate B", "at": {"occurrence": 0, "point": [0,0,0], "z": normal}, "name": "Slide"}), + ); + run(&mut s, "contact.create", json!({"occurrences": ["Plate A", "Plate B"]})); + let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -4})); + assert!(result["stopped_by_contact"].is_object(), "{result}"); + let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0]; + assert!(value > -2.0 && value < -1.9, "{value}"); +} + +#[test] +fn ordinary_curved_bodies_keep_a_practical_sampling_step() { + for curved in ["sphere", "fillet"] { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Blocker"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0,-5,-5]})); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "component.create", json!({"name": "Curved"})); + if curved == "sphere" { + run(&mut s, "solid.sphere", json!({"radius": 5, "center": [20,0,0]})); + } else { + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [15,-5,-5]})); + run(&mut s, "solid.fillet", json!({"edges": [[15,-5,0]], "radius": 2})); + } + run( + &mut s, + "joint.as_built", + json!({"type": "slider", "a": 0, "b": "Curved", "at": {"occurrence": 0, "point": [0,0,0], "z": [1,0,0]}, "name": "Slide"}), + ); + run(&mut s, "contact.create", json!({"occurrences": ["Blocker", "Curved"]})); + let geometry = super::ContactGeometry::new(&s).unwrap(); + assert!(geometry.step > 1.0, "{curved}: tessellation must not force tiny steps: {}", geometry.step); + let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -30})); + assert!(result["stopped_by_contact"].is_object(), "{curved}: {result}"); + let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0]; + assert!(value > -5.02 && value < -4.9, "{curved}: {value}"); + } +} + +#[test] +fn initially_overlapping_assembly_can_move_out_for_recovery() { + let mut s = slider(); + run(&mut s, "contact.disable_all", json!({})); + run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5})); + run(&mut s, "contact.enable_sets", json!({"enabled": true})); + assert!(!run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty()); + let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10})); + assert!(result["stopped_by_contact"].is_null(), "recovery must not stick: {result}"); + assert!((rail_value(&s) - 10.0).abs() < 1e-6); + assert!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty()); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cdfcd4cc..ddf62105 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -296,36 +296,111 @@ fn reorder(s: &mut Session, p: &Value) -> Result { Ok(json!({"feature": id, "position": to})) } +fn input_keys(kind: &FeatureKind) -> Vec<(String, solvecraft_doc::expr::Kind)> { + let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect(); + if let FeatureKind::Fillet { style, .. } = kind { + if let Some((key, _)) = keys.first_mut() { + *key = "fillet.radius".into(); + } + if let solvecraft_doc::FilletStyle::Variable { mid, .. } = style { + for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) { + *key = format!("fillet.mid.{}", position.to_bits()); + } + } + } + keys +} + fn redefine(s: &mut Session, p: &Value) -> Result { let cmd = "timeline.redefine"; let id = feature_id(s, p.get("feature"), cmd)?; let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?; let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?; - if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") { + // Only modeling constructors may run here. In particular, a scratch session does not + // isolate filesystem writes performed by export commands. + let constructor = super::features::COMMANDS + .iter() + .chain(super::features_more::COMMANDS) + .chain(super::construct::COMMANDS) + .chain(super::surface::COMMANDS) + .any(|candidate| candidate.id == spec.id) + || matches!( + spec.id, + "sheet.flange" + | "sheet.hem" + | "sheet.fold" + | "sheet.unfold" + | "sheet.refold" + | "sheet.convert" + | "plastic.boss" + | "plastic.lip" + | "plastic.snap_fit" + | "plastic.rest" + ); + if !constructor { return Err(bad(cmd, format!("`{cid}` does not make a feature"))); } - let params = p.get("params").cloned().unwrap_or_else(|| json!({})); + let mut params = p.get("params").cloned().unwrap_or_else(|| json!({})); let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?; let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?; - let marker = s.doc.marker; - // Build the new definition where the feature sits, against the model before it. - s.doc_mut().marker = Some(idx); - s.refresh(); - let r = (spec.run)(s, ¶ms); - let made = r.as_ref().ok().and_then(|v| v.get("feature")).and_then(Value::as_u64); - let new = made.and_then(|nid| s.doc.feature(nid).cloned()); - if let Some(nid) = made { - s.doc_mut().features.retain(|f| f.id != nid); + if !old.participants.is_empty() + && let Some(parameters) = params.as_object_mut() + { + parameters.entry("participants").or_insert_with(|| json!(old.participants)); } - s.doc_mut().marker = marker; - r?; - let new = new.ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; + // Run the constructor on a scratch session: failed edits must not disturb sketch mode, + // selections, component authoring state or the original feature's definition. + let mut trial = s.scratch(); + trial.active_component = old.component; + trial.active_sketch = None; + trial.selection = s.selection.clone(); + trial.doc_mut().marker = Some(idx); + trial.refresh(); + (spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; + let r = (spec.run)(&mut trial, ¶ms)?; + let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; + let new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) { return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid))); } - if let Some(slot) = s.doc_mut().feature_mut(id) { - slot.kind = new.kind; + // Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper). + // Keep each existing model parameter attached to its semantic input, rather than its + // old index. Constructor-assigned names for new inputs cannot reuse the old dimensions. + let mut old_inputs: Vec<_> = input_keys(&old.kind).into_iter().zip(&old.param_names).collect(); + let param_names: Vec = input_keys(&new.kind) + .iter() + .enumerate() + .map(|(i, (label, kind))| { + if let Some(k) = old_inputs.iter().position(|((old_label, old_kind), _)| old_label == label && old_kind == kind) { + old_inputs.remove(k).1.clone() + } else { + new.param_names.get(i).cloned().unwrap_or_default() + } + }) + .collect(); + trial.doc_mut().features.retain(|f| f.id != made); + let added = trial.doc.features.len().saturating_sub(s.doc.features.len()); + trial.doc_mut().marker = s.doc.marker.map(|marker| if marker > idx { marker.saturating_add(added) } else { marker }); + if made == s.doc.next_id && trial.doc.next_id == made.saturating_add(1) { + trial.doc_mut().next_id = s.doc.next_id; } + if let Some(slot) = trial.doc_mut().feature_mut(id) { + slot.kind = new.kind; + slot.edge_refs = new.edge_refs; + slot.edge_names = new.edge_names; + slot.face_names = new.face_names; + slot.param_names = param_names; + if params.get("participants").is_some() { + slot.participants = new.participants; + } + } + // New optional inputs need model parameter names, while existing dimensions keep theirs. + trial.doc_mut().name_feature_inputs(id); + trial.refresh(); + if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) { + return Err(EngineError::Other(e)); + } + s.doc = trial.doc; s.refresh(); if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { return Err(EngineError::Other(e)); @@ -344,3 +419,7 @@ fn dependents(s: &mut Session, p: &Value) -> Result { let broken: Vec = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect(); Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken})) } + +#[cfg(test)] +#[path = "edit_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/edit_tests.rs b/crates/engine/src/cmd/edit_tests.rs new file mode 100644 index 00000000..bb289609 --- /dev/null +++ b/crates/engine/src/cmd/edit_tests.rs @@ -0,0 +1,268 @@ +//! Edit Feature must replace the selections as well as the numerical definition. + +use serde_json::{Value, json}; + +use crate::{Sel, Session}; + +fn run(s: &mut Session, id: &str, params: Value) -> Value { + s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}")) +} + +#[test] +fn redefining_a_blend_replaces_persistent_edge_selection_and_survives_resize() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"})); + run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 2, "name": "Round"})); + let round_id = s.doc.find_feature("Round").unwrap().id; + let body_name = s.model.state().bodies[0].name.clone(); + s.doc_mut().feature_mut(round_id).unwrap().participants = vec![body_name]; + s.refresh(); + let old = s.doc.find_feature("Round").unwrap().clone(); + let before = (*s.doc).clone(); + run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[0, 15, 20]], "radius": 2}})); + let edited = s.doc.find_feature("Round").unwrap().clone(); + assert_eq!(edited.id, old.id); + assert_eq!(edited.param_names, old.param_names); + assert_eq!(edited.participants, old.participants); + assert_ne!(edited.edge_names, old.edge_names, "the new edge must be the reference used on recompute"); + assert_ne!(edited.edge_refs, old.edge_refs); + assert_eq!(s.doc.next_id, before.next_id, "temporary definitions must not consume feature ids"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*s.doc, before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.find_feature("Round").unwrap().edge_names, edited.edge_names); + run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60", "height": "25"}})); + let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap(); + let expected = 60.0 * 30.0 * 25.0 - (4.0 - std::f64::consts::PI) * 30.0; + assert!((volume - expected).abs() < expected * 1e-4, "{volume} != {expected}"); + assert!(s.model.result(old.id).unwrap().warning.is_none()); +} + +#[test] +fn redefining_preserves_body_names_participants_and_marker_without_temporary_features() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base", "body_name": "Housing"})); + run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0], "name": "Other"})); + let id = s.doc.find_feature("Base").unwrap().id; + run(&mut s, "timeline.rollback", json!({"position": 1})); + let before = s.doc.feature(id).unwrap().clone(); + run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.box", "params": {"length": 25, "width": 10, "height": 5}})); + let after = s.doc.feature(id).unwrap(); + assert_eq!(s.doc.features.len(), 2); + assert_eq!(s.doc.marker, Some(1)); + assert_eq!(after.body_names, before.body_names); + assert_eq!(after.participants, before.participants); + assert_eq!(after.param_names, before.param_names); + assert_eq!(after.name, before.name); + assert!(s.model.state().body("Housing").is_some()); +} + +#[test] +fn failed_or_non_feature_redefinitions_leave_session_and_history_intact() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base"})); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + s.selection = vec![Sel::Feature { id: s.doc.find_feature("Base").unwrap().id }]; + let doc = s.doc.clone(); + let sketch = s.active_sketch; + let selection = s.selection.clone(); + let undo = s.undo.len(); + for (command, params) in [ + ("solid.cylinder", json!({"radius": 5, "height": 10})), + ("solid.box", json!({"length": "unknown_parameter", "width": 10, "height": 5})), + ("file.export", json!({"path": "should-not-be-created.step"})), + ("edit.undo", json!({})), + ] { + assert!(s.execute("timeline.redefine", &json!({"feature": "Base", "command": command, "params": params})).is_err()); + assert_eq!(*s.doc, *doc, "{command}"); + assert_eq!(s.active_sketch, sketch, "{command}"); + assert_eq!(s.selection, selection, "{command}"); + assert_eq!(s.undo.len(), undo, "{command}"); + } +} + +#[test] +fn redefining_an_extrude_names_new_optional_inputs() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Pad"})); + let distance = s.doc.find_feature("Pad").unwrap().param_names[0].clone(); + run(&mut s, "timeline.redefine", json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "start_offset": 2}})); + let f = s.doc.find_feature("Pad").unwrap(); + assert_eq!(f.param_names.len(), f.kind.inputs().len()); + assert_eq!(f.param_names[0], distance); + assert!(f.param_names.iter().all(|name| !name.is_empty())); + assert_eq!(s.doc.features.len(), 2); + let offset = s.doc.find_feature("Pad").unwrap().param_names.last().unwrap().clone(); + run( + &mut s, + "timeline.redefine", + json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "distance2": 2, "start_offset": 2}}), + ); + let f = s.doc.find_feature("Pad").unwrap(); + let inputs = f.kind.inputs(); + let offset_index = inputs.iter().position(|(label, _, _)| *label == "Start offset").unwrap(); + assert_eq!(f.param_names[offset_index], offset, "adding Distance 2 must not steal the offset dimension's parameter name"); + let distance2_index = inputs.iter().position(|(label, _, _)| *label == "Distance 2").unwrap(); + assert_ne!(f.param_names[distance2_index], offset); +} + +#[test] +fn redefining_a_shell_replaces_persistent_face_selection() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"})); + run(&mut s, "solid.shell", json!({"faces": [[20, 15, 20]], "thickness": 2, "name": "Hollow"})); + let before = s.doc.find_feature("Hollow").unwrap().face_names.clone(); + assert!(!before.is_empty()); + run(&mut s, "timeline.redefine", json!({"feature": "Hollow", "command": "solid.shell", "params": {"faces": [[20, 15, 0]], "thickness": 2}})); + let after = s.doc.find_feature("Hollow").unwrap().face_names.clone(); + assert!(!after.is_empty()); + assert_ne!(before, after); + run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "30"}})); + let id = s.doc.find_feature("Hollow").unwrap().id; + assert!(s.model.result(id).unwrap().error.is_none()); + assert!(s.model.result(id).unwrap().warning.is_none()); + assert_eq!(s.doc.find_feature("Hollow").unwrap().face_names, after); +} + +#[test] +fn redefine_rejects_sheet_exports_before_they_touch_any_files() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"name": "Plate"})); + let doc = s.doc.clone(); + let base = std::env::temp_dir().join(format!("solvecraft-redefine-export-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + for command in ["sheet.export_dxf", "sheet.export_bend_table"] { + for existing in [false, true] { + let path = base.join(format!("{}-{existing}.out", command.replace('.', "-"))); + if existing { + std::fs::write(&path, b"original file").unwrap(); + } else if path.exists() { + std::fs::remove_file(&path).unwrap(); + } + let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"path": path}})).unwrap_err(); + assert!(error.to_string().contains("does not make a feature"), "{error}"); + assert_eq!(*s.doc, *doc); + if existing { + assert_eq!(std::fs::read(&path).unwrap(), b"original file"); + std::fs::remove_file(&path).unwrap(); + } else { + assert!(!path.exists(), "{command} must not create a file before refusing the edit"); + } + } + } + for command in ["sheet.manage_rules", "sheet.rules", "sheet.flat_pattern", "parameters.list", "plastic.manage_rules"] { + let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"name": "unexpected"}})).unwrap_err(); + assert!(error.to_string().contains("does not make a feature"), "{error}"); + assert_eq!(*s.doc, *doc); + } + std::fs::remove_dir(&base).unwrap(); +} + +#[test] +fn redefine_keeps_allocator_above_component_and_occurrence_ids() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"})); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [10, 0, 0], "body_name": "Tool"})); + run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Tool"], "name": "Join"})); + run( + &mut s, + "timeline.redefine", + json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}), + ); + let max_id = + s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).max().unwrap(); + assert!(s.doc.next_id > max_id); + run(&mut s, "component.create", json!({"name": "Next", "parent": 0})); + run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2})); + let ids: Vec = + s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).collect(); + let unique: std::collections::BTreeSet = ids.iter().copied().collect(); + assert_eq!(ids.len(), unique.len(), "feature, component and occurrence ids must remain unique: {ids:?}"); +} + +#[test] +fn redefine_maps_placement_inputs_in_the_feature_owner_frame() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "name": "RootBox", "body_name": "RootBody"})); + let owner = run(&mut s, "component.create", json!({"name": "Owner"})); + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "name": "OwnedBox", "body_name": "OwnedBody"})); + run(&mut s, "solid.fillet", json!({"edges": [[12, 0, 4]], "radius": 0.5, "body": "OwnedBody", "name": "OwnerRound"})); + run(&mut s, "occurrence.move", json!({"occurrence": owner["occurrence"], "translate": [100, 0, 0]})); + let other = run(&mut s, "component.create", json!({"name": "Other", "parent": 0})); + run(&mut s, "occurrence.move", json!({"occurrence": other["occurrence"], "translate": [200, 0, 0]})); + let active = s.active_component; + run( + &mut s, + "timeline.redefine", + json!({"feature": "RootBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [2, 0, 0]}}), + ); + run( + &mut s, + "timeline.redefine", + json!({"feature": "OwnedBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [110, 0, 0]}}), + ); + run( + &mut s, + "timeline.redefine", + json!({"feature": "OwnerRound", "command": "solid.fillet", "params": {"edges": [[112, 0, 4]], "radius": 0.75, "body": "OwnedBody"}}), + ); + assert_eq!(s.active_component, active, "editing must not activate the owner in the real session"); + assert_eq!(s.doc.find_feature("OwnedBox").unwrap().component, owner["component"].as_u64().unwrap()); + let root = run(&mut s, "inspect.measure", json!({"bodies": ["RootBody"]})); + let owned = run(&mut s, "inspect.measure", json!({"bodies": ["OwnedBody"]})); + assert!((root["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 2.0).abs() < 1e-8); + assert!((owned["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 110.0).abs() < 1e-8); +} + +#[test] +fn redefining_fillet_style_keeps_primary_parameter_and_downstream_references() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30})); + run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 2, "name": "Round"})); + let parameter = s.doc.find_feature("Round").unwrap().param_names[0].clone(); + run(&mut s, "solid.box", json!({"length": parameter, "width": 2, "height": 2, "corner": [50, 0, 0], "name": "Dependent"})); + run( + &mut s, + "timeline.redefine", + json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 3, "type": "chord"}}), + ); + assert_eq!(s.doc.find_feature("Round").unwrap().param_names[0], parameter); + let dependent = s.doc.find_feature("Dependent").unwrap().id; + assert!(s.model.result(dependent).unwrap().error.is_none()); + let value = run(&mut s, "expr.evaluate", json!({"expression": parameter})); + assert_eq!(value["value"], 3.0); +} + +#[test] +fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30})); + run( + &mut s, + "solid.fillet", + json!({"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.25, 3], [0.75, 4]], "name": "Round"}), + ); + let parameters = s.doc.find_feature("Round").unwrap().param_names.clone(); + run(&mut s, "solid.box", json!({"length": parameters[2], "width": parameters[3], "height": 2, "corner": [50, 0, 0], "name": "Dependent"})); + run( + &mut s, + "timeline.redefine", + json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.1, 2.5], [0.25, 3], [0.75, 4]]}}), + ); + let edited = s.doc.find_feature("Round").unwrap(); + assert_eq!(edited.param_names[0..2], parameters[0..2]); + assert_eq!(edited.param_names[3..5], parameters[2..4]); + assert!(!parameters.contains(&edited.param_names[2]), "the inserted control needs a new dimension"); + let dependent = s.doc.find_feature("Dependent").unwrap().id; + assert!(s.model.result(dependent).unwrap().error.is_none()); + let length = run(&mut s, "expr.evaluate", json!({"expression": parameters[2]})); + let width = run(&mut s, "expr.evaluate", json!({"expression": parameters[3]})); + assert_eq!(length["value"], 3.0); + assert_eq!(width["value"], 4.0); +} diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 96541ac7..42bb6b66 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -12,6 +12,9 @@ use super::joints::{joint_id, occurrence_id, resolve, value}; use crate::params::{bad, bool_, str_, vec3}; use crate::{EngineError, Result, Session}; +#[path = "contact_sweep.rs"] +mod contact_sweep; + pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params( "enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other", @@ -87,7 +90,11 @@ fn placed(s: &Session) -> Vec<(Vec, Body)> { continue; } for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) { - if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, m) { + // Moving a body into another component preserves its world placement using an + // offset from its authoring frame. Contact and explode centres must use that + // same offset as display and physical properties. + let frame = s.doc.body_offsets.get(&b.name).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m); + if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) { out.push((path.clone(), t)); } } @@ -131,8 +138,11 @@ pub fn collisions(s: &Session) -> Vec<(String, String, f64)> { /// tested against the other closed mesh: inside by ray parity, and deeper than the tolerance. fn overlap(a: &Body, b: &Body) -> Option { let (ma, mb) = (a.tessellate((a.size() * 1e-3).max(1e-3)).ok()?, b.tessellate((b.size() * 1e-3).max(1e-3)).ok()?); + mesh_overlap(&ma, &mb, 1e-4 * a.size().max(b.size()).max(1.0)) +} + +fn mesh_overlap(ma: &solvecraft_geom::Mesh, mb: &solvecraft_geom::Mesh, tol: f64) -> Option { let (ba, bb) = (ma.bounds(), mb.bounds()); - let tol = 1e-4 * a.size().max(b.size()).max(1.0); if ba.max.x < bb.min.x + tol || bb.max.x < ba.min.x + tol || ba.max.y < bb.min.y + tol @@ -142,7 +152,7 @@ fn overlap(a: &Body, b: &Body) -> Option { { return None; } - let depth = deepest(&ma, &mb, tol).max(deepest(&mb, &ma, tol)); + let depth = deepest(ma, mb, tol).max(deepest(mb, ma, tol)); (depth > tol).then_some(depth) } @@ -247,9 +257,12 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 { /// Set joint values, stopping short where contact sets would collide: the values are moved /// from where they are toward `want` as far as they go without a collision. fn drive_with_contact(s: &mut Session, want: &[(u64, Vec)]) -> Result { + let baseline = s.doc.clone(); let start: Vec<(u64, Vec)> = want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect(); let set = |s: &mut Session, t: f64| { + // Trial order must not affect free anchors or linked joint placements. + s.doc = baseline.clone(); let doc = s.doc_mut(); for ((id, a), (_, b)) in start.iter().zip(want) { if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) { @@ -260,25 +273,45 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec)]) -> Result = want .iter() diff --git a/crates/engine/src/cmd/motion_tests.rs b/crates/engine/src/cmd/motion_tests.rs index dbf1f062..866adbe7 100644 --- a/crates/engine/src/cmd/motion_tests.rs +++ b/crates/engine/src/cmd/motion_tests.rs @@ -71,6 +71,40 @@ fn contact_sets_stop_a_driven_joint_at_touching() { assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err()); } +#[test] +fn contact_uses_preserved_body_placements_after_component_reassignment() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"})); + let component = run(&mut s, "component.create", json!({"name": "Carrier"})); + run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]})); + run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]})); + run(&mut s, "component.activate", json!({"component": "root"})); + let probe = run(&mut s, "component.create", json!({"name": "Probe"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 0, 0]})); + run(&mut s, "contact.create", json!({"occurrences": [component["occurrence"], probe["occurrence"]]})); + let collisions = run(&mut s, "contact.check", json!({})); + assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(1), "body stayed at x=0: {collisions}"); + run(&mut s, "occurrence.move", json!({"occurrence": probe["occurrence"], "translate": [100, 0, 0]})); + let collisions = run(&mut s, "contact.check", json!({})); + assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(0), "probe moved away: {collisions}"); +} + +#[test] +fn automatic_explode_centres_follow_reassigned_bodies() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"})); + let component = run(&mut s, "component.create", json!({"name": "Carrier"})); + run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]})); + run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Neighbour"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]})); + let exploded = run(&mut s, "explode.create", json!({"scale": 1})); + let moves = exploded["moves"].as_array().unwrap(); + let carrier = moves.iter().find(|m| m["occurrence"] == component["occurrence"]).unwrap(); + assert!((carrier["translate"][0].as_f64().unwrap() + 10.0).abs() < 1e-6, "{exploded}"); +} + #[test] fn motion_study_plays_and_exports() { let mut s = rail(); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index bbaf5333..39976b67 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -45,6 +45,9 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf) .noundo() .params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"), + CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table) + .noundo() + .params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"), ]; fn points(p: &Value, k: &str, cmd: &str) -> Result> { @@ -142,8 +145,9 @@ fn fold(s: &mut Session, p: &Value) -> Result { let cmd = "sheet.fold"; let (sketch, curve, a, b) = match p.get("points") { Some(v) => { - let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::>()).unwrap_or_default(); - let [a, b] = pts[..] else { return Err(bad(cmd, "`points` must be two points [x, y, z]")) }; + let pts = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`points` must be two points [x, y, z]"))?; + let a = pts.first().and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?; + let b = pts.get(1).and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?; (None, None, a, b) } None => { @@ -306,6 +310,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { w(2, "TABLES"); w(0, "TABLE"); w(2, "LAYER"); + w(70, "3"); for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] { w(0, "LAYER"); w(2, name); @@ -338,17 +343,25 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { for seg in &h.outer.segs { match *seg { solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => { + if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 { + w(0, "CIRCLE"); + w(8, "OUTLINE"); + w(10, &f(center.x)); + w(20, &f(center.y)); + w(40, &f(radius)); + continue; + } let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) }; w(0, "ARC"); w(8, "OUTLINE"); w(10, &f(center.x)); w(20, &f(center.y)); w(40, &f(radius)); - w(50, &f(a0.to_degrees())); - w(51, &f(a1.to_degrees())); + w(50, &f(a0.to_degrees().rem_euclid(360.0))); + w(51, &f(a1.to_degrees().rem_euclid(360.0))); } other => { - let pts: Vec = (0..=8).map(|k| other.point_at(k as f64 / 8.0)).collect(); + let pts = other.polyline(1e-3); for p in pts.windows(2) { line(&mut w, "OUTLINE", p[0], p[1]); } @@ -375,6 +388,50 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result { Ok(json!({"dxf": text, "bends": sh.bends().len()})) } +fn export_bend_table(s: &mut Session, p: &Value) -> Result { + let cmd = "sheet.export_bend_table"; + let format = str_(p, "format").unwrap_or("csv").to_ascii_lowercase(); + if !matches!(format.as_str(), "csv" | "json") { + return Err(bad(cmd, "`format` must be csv or json")); + } + let sh = sheet_of(s, p, cmd)?; + let table = flat_json(&sh)?; + let text = if format == "json" { + serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))? + } else { + // Always quote text fields: body/rule names may include commas, quotes or newlines. + let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\"")); + let mut csv = + "body,rule,thickness_mm,k_factor,bend,angle_deg,direction,radius_mm,allowance_mm,length_mm,start_x_mm,start_y_mm,end_x_mm,end_y_mm\r\n" + .to_string(); + for b in sh.bends() { + csv.push_str(&format!( + "{},{},{:.9},{:.9},{},{:.9},{},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9}\r\n", + quote(&sh.body), + quote(&sh.rule), + sh.t, + sh.k, + b.index, + b.angle_deg, + if b.up { "up" } else { "down" }, + b.radius, + b.allowance, + b.length, + b.start.x, + b.start.y, + b.end.x, + b.end.y + )); + } + csv + }; + if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) { + solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + return Ok(json!({"path": path, "format": format, "bends": sh.bends().len()})); + } + Ok(json!({"format": format, "text": text, "bends": sh.bends().len()})) +} + #[cfg(test)] #[path = "sheet_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index c5b367be..3bfe4c6b 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -344,3 +344,80 @@ fn fold_keeps_the_larger_side_either_way() { } } } + +#[test] +fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() { + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "thickness": 2, "bend_radius": 3, "k_factor": 0.4})); + base(&mut s, 100.0, 60.0); + run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20})); + let undo = s.undo.len(); + let csv = run(&mut s, "sheet.export_bend_table", json!({})); + let text = csv["text"].as_str().unwrap(); + assert!(text.starts_with("body,rule,thickness_mm,k_factor,bend,angle_deg,direction,")); + assert!(text.contains("\"Shop, \"\"steel\"\"\""), "{text}"); + assert_eq!(text.lines().count(), 2); + assert_eq!(csv["bends"], 1); + let exported = run(&mut s, "sheet.export_bend_table", json!({"format": "JSON"})); + let table: Value = serde_json::from_str(exported["text"].as_str().unwrap()).unwrap(); + assert_eq!(table["thickness"], 2.0); + assert_eq!(table["bends"][0]["direction"], "up"); + assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 3.8).abs() < 1e-9); + assert_eq!(s.undo.len(), undo, "export does not create an undo step"); + run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "k_factor": 0.5})); + let updated = run(&mut s, "sheet.export_bend_table", json!({"format": "json"})); + let table: Value = serde_json::from_str(updated["text"].as_str().unwrap()).unwrap(); + assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 4.0).abs() < 1e-9); + assert!(s.execute("sheet.export_bend_table", &json!({"format": "xml"})).is_err()); + assert!(s.execute("sheet.export_bend_table", &json!({"body": "Missing"})).is_err()); + let mut empty = Session::default(); + assert!(empty.execute("sheet.export_bend_table", &json!({})).is_err()); +} + +#[test] +fn malformed_fold_points_are_rejected_atomically() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let before = serde_json::to_value(&s.doc).unwrap(); + for points in [ + json!([[40, 0, 2.5], null, [40, 60, 2.5]]), + json!([[40, 0, 2.5], [40, 60, 2.5], "junk"]), + json!([[40, 0, 2.5], [40, 60]]), + json!([[40, 0, 2.5]]), + json!("not points"), + ] { + assert!(s.execute("sheet.fold", &json!({"points": points})).is_err()); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); + } +} + +#[test] +fn flat_dxf_preserves_exact_cutout_entities() { + use solvecraft_geom::{Loop2, Region2, Seg2, Vec2}; + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let mut sh = s.model.state().sheets[0].clone(); + sh.holes = vec![Region2 { + outer: Loop2 { segs: vec![Seg2::Arc { center: Vec2::new(40.0, 30.0), radius: 5.0, start: -PI, sweep: -2.0 * PI }] }, + holes: Vec::new(), + }]; + let dxf = super::flat_dxf(&sh).unwrap(); + assert_eq!(dxf.matches("0\nCIRCLE\n").count(), 1, "{dxf}"); + assert!(!dxf.contains("0\nARC\n"), "a full circle must not become a zero-length ARC"); + let read = solvecraft_io::read_dxf(&dxf).unwrap(); + assert_eq!(read.len(), 5); + assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Circle(c, r) if *c == Vec2::new(40.0, 30.0) && (*r - 5.0).abs() < 1e-9))); + sh.holes[0].outer = Loop2 { + segs: vec![ + Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(30.0, 10.0) }, + Seg2::Arc { center: Vec2::new(20.0, 10.0), radius: 10.0, start: 0.0, sweep: PI }, + ], + }; + let dxf = super::flat_dxf(&sh).unwrap(); + assert_eq!(dxf.matches("0\nLINE\n").count(), 5, "four outline lines and one exact cutout line"); + assert_eq!(dxf.matches("0\nARC\n").count(), 1); + assert!(dxf.contains("50\n0.000000\n51\n180.000000\n")); + let read = solvecraft_io::read_dxf(&dxf).unwrap(); + assert_eq!(read.len(), 6); + assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Arc { c, a, b } if *c == Vec2::new(20.0, 10.0) && a.dist(Vec2::new(30.0, 10.0)) < 1e-9 && b.dist(Vec2::new(10.0, 10.0)) < 1e-9))); +} diff --git a/crates/engine/src/cmd/sketch_freeform.rs b/crates/engine/src/cmd/sketch_freeform.rs index 0ec0e92f..f1e884d0 100644 --- a/crates/engine/src/cmd/sketch_freeform.rs +++ b/crates/engine/src/cmd/sketch_freeform.rs @@ -12,7 +12,7 @@ use crate::params::bad; /// Exact pieces of a free-form curve (polyline lines for the few kinds without them). fn pieces(sk: &Sketch, ci: usize) -> Vec { - sk.exact_segs(ci).unwrap_or_else(|| sk.polyline(ci).windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect()) + sk.exact_segs(ci).unwrap_or_else(|| sk.segs(ci)) } /// Position along the pieces (piece index + local parameter) closest to `p`, and the distance. @@ -44,7 +44,7 @@ fn project(sk: &Sketch, ci: usize, p: Vec2) -> Vec2 { } /// Where other curves meet free-form curve `ci`: (position along it, the point, the other curve). -fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> { +fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2], only_freeform: bool) -> Vec<(f64, Vec2, usize)> { let mine = sk.polyline(ci); let tol = 1e-6; let mut out: Vec<(f64, Vec2, usize)> = Vec::new(); @@ -57,8 +57,8 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> { let (t, u) = ((c - a).cross(s) / den, (c - a).cross(r) / den); ((-1e-9..=1.0 + 1e-9).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&u)).then(|| a + r * t) }; - for (oi, _) in sk.curves.iter().enumerate() { - if oi == ci { + for (oi, curve) in sk.curves.iter().enumerate() { + if oi == ci || (only_freeform && !curve.kind.is_freeform()) { continue; } let other = sk.polyline(oi); @@ -90,7 +90,7 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> { q = n; } let (u, d) = locate(segs, q); - if d < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) { + if d < tol && project(sk, oi, q).dist(q) < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) { out.push((u, q, oi)); } } @@ -99,6 +99,15 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> { out } +/// Crossings for an analytic target with free-form cutters. Analytic-to-analytic crossings +/// remain in the shape intersection routine; this supplies the missing mixed pairs. +pub(super) fn analytic_cut_points(sk: &Sketch, ci: usize) -> Vec<(Vec2, usize)> { + if !sk.curves.iter().any(|c| c.kind.is_freeform()) { + return Vec::new(); + } + cuts(sk, ci, &pieces(sk, ci), true).into_iter().map(|(_, q, oi)| (q, oi)).collect() +} + /// Exact sub-pieces between positions `ua` < `ub` (positions may run past the end on closed /// curves; they wrap). fn span(segs: &[Seg2], ua: f64, ub: f64) -> Vec { @@ -176,18 +185,45 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option, end: Option< /// Trim (`remove`) or break a free-form curve at the crossings around `at`. pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, cmd: &str) -> Result { + modify(sk, ci, at, remove, None, cmd) +} + +/// Break at explicit positions, projected onto the exact curve. Closed curves can be opened +/// at one point; open curves require interior positions. +pub(super) fn break_at_points(sk: &mut Sketch, ci: usize, points: &[Vec2], cmd: &str) -> Result { + modify(sk, ci, Vec2::ZERO, false, Some(points), cmd) +} + +fn modify(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, points: Option<&[Vec2]>, cmd: &str) -> Result { let segs = pieces(sk, ci); if segs.is_empty() { return Err(bad(cmd, "the curve has no shape")); } let n = segs.len() as f64; let closed = segs.first().zip(segs.last()).is_some_and(|(a, b)| a.start().dist(b.end()) < 1e-9); - let cs: Vec<(f64, Vec2, usize)> = cuts(sk, ci, &segs).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).collect(); + let cs: Vec<(f64, Vec2, Option)> = if let Some(points) = points { + let mut positions: Vec = points.iter().map(|q| locate(&segs, *q).0).collect(); + if positions.iter().any(|u| !u.is_finite() || (!closed && (*u <= 1e-9 || *u >= n - 1e-9))) { + return Err(bad(cmd, "break points must project inside the curve, away from its ends")); + } + if closed { + for u in &mut positions { + if *u >= n - 1e-9 { + *u = 0.0; + } + } + } + positions.sort_by(f64::total_cmp); + positions.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); + positions.into_iter().map(|u| (u, point_at(&segs, u), None)).collect() + } else { + cuts(sk, ci, &segs, false).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).map(|(u, q, c)| (u, q, Some(c))).collect() + }; let (uq, _) = locate(&segs, at); let ends = sk.curves.get(ci).and_then(|c| c.kind.ends()); let construction = sk.curves.get(ci).is_some_and(|c| c.construction); // Intervals between consecutive cuts (closed curves wrap round). - let mut bounds: Vec<(f64, Option)> = cs.iter().map(|c| (c.0, Some(c.2))).collect(); + let mut bounds: Vec<(f64, Option)> = cs.iter().map(|c| (c.0, c.2)).collect(); if bounds.is_empty() { if remove { sk.remove_curves(&[ci]); @@ -239,7 +275,7 @@ pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, // Cut points stay on the curves that cut there. for ((_, _, oi), (_, pi)) in cs.iter().zip(&cut_pt) { let used = sk.curves.iter().any(|c| c.kind.uses(*pi)); - if used { + if used && let Some(oi) = oi { add_c(sk, ConstraintKind::PointOnCurve { p: *pi, c: *oi })?; } } diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index f79c3697..69575862 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -9,7 +9,7 @@ use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, interse use super::sketch::{add_c, edit, ids_of}; use super::{CommandSpec, in_sketch}; -use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2}; +use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ @@ -54,7 +54,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("break") .enabled(in_sketch) - .params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"), + .params("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)"), CommandSpec::new("sketch.scale", "Sketch Scale", scale) .at("SKETCH", "MODIFY") .icon("scale") @@ -135,6 +135,13 @@ fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> { } } } + for (q, oi) in super::sketch_freeform::analytic_cut_points(sk, ci) { + if let Some(t) = on_bounded(&sh, q) + && !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0) + { + out.push((t, q, oi)); + } + } out } @@ -314,17 +321,24 @@ fn trim(s: &mut Session, p: &Value) -> Result { fn break_cmd(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.break"; - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None }; + let (out, info) = edit(s, p, cmd, false, |sk, doc| { let ci = curve_arg(sk, p, cmd)?; if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) { - let at = req_vec2(cmd, p, "at")?; - let r = super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?; + let r = if let Some(points) = &points { + super::sketch_freeform::break_at_points(sk, ci, points, cmd)? + } else { + let at = req_vec2(cmd, p, "at")?; + super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)? + }; + doc.prune_model_params(); return Ok(r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()); } let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?; - let (ts, cutters): (Vec, Vec>) = if let Some(list) = p.get("points").and_then(Value::as_array) { - let mut v: Vec = list.iter().take(1000).filter_map(vec2).map(|q| sh.param(sh.project(q))).collect(); + let (ts, cutters): (Vec, Vec>) = if let Some(list) = &points { + let mut v: Vec = list.iter().map(|q| sh.param(sh.project(*q))).collect(); v.sort_by(|a, b| a.total_cmp(b)); + v.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); let n = v.len(); (v, vec![None; n]) } else { diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 51916014..5f13604d 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -37,6 +37,102 @@ fn close(a: f64, b: f64) -> bool { (a - b).abs() < 1e-6 } +#[test] +fn explicit_freeform_break_preserves_exact_geometry_and_undo() { + for (command, params) in [ + ("sketch.spline.control_point", json!({"points": [[0,0], [0,12], [20,12], [20,0]], "connect": false})), + ("sketch.spline.control_point_5", json!({"points": [[0,0], [0,12], [5,20], [12,20], [20,12], [20,0]], "connect": false})), + ("sketch.spline.fit_point", json!({"points": [[0,0], [6,12], [14,8], [20,0]], "connect": false})), + ("sketch.conic", json!({"start": [0,0], "end": [20,0], "apex": [10,20], "rho": 0.65, "connect": false})), + ] { + let mut s = new_sketch(); + let id = ids(&run(&mut s, command, params)["curves"])[0].clone(); + let before = sketch(&s); + let exact = before.exact_segs(before.curve_index(&id).unwrap()).unwrap(); + let a = exact.first().unwrap().point_at(0.25); + let b = exact.last().unwrap().point_at(0.75); + let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [b, a, a]})); + assert!(ids(&out["curves"]).len() >= 3, "{command}: {out}"); + let after = sketch(&s); + let segs: Vec<_> = after.curves.iter().enumerate().flat_map(|(i, _)| after.exact_segs(i).unwrap_or_default()).collect(); + for old in &exact { + for k in 0..=40 { + let q = old.point_at(k as f64 / 40.0); + let d = segs.iter().map(|seg| seg.point_at(seg.closest_param(q)).dist(q)).fold(f64::INFINITY, f64::min); + assert!(d < 1e-6, "{command}: geometry moved {d} at {q:?}"); + } + } + let encoded = serde_json::to_value(&after).unwrap(); + let restored: solvecraft_sketch::Sketch = serde_json::from_value(encoded).unwrap(); + assert_eq!(restored.curves.len(), after.curves.len()); + run(&mut s, "edit.undo", json!({})); + assert!(sketch(&s).curve_index(&id).is_some()); + } +} + +#[test] +fn explicit_ellipse_break_keeps_closed_profile() { + let mut s = new_sketch(); + let id = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [10,0], "minor": [0,4]}))["curves"])[0].clone(); + let area = profiles(&s)[0]; + let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,0], [0,4], [10,0]]})); + assert_eq!(ids(&out["curves"]).len(), 4, "{out}"); + let after = sketch(&s); + assert!(after.curves.iter().all(|c| matches!(c.kind, CurveKind::Conic { .. }))); + assert_eq!(profiles(&s).len(), 1); + assert!((profiles(&s)[0] - area).abs() < 1e-6); + run(&mut s, "edit.undo", json!({})); + let original = sketch(&s); + let q = original.exact_segs(original.curve_index(&id).unwrap()).unwrap()[0].point_at(0.4); + let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [q]})); + assert_eq!(ids(&out["curves"]).len(), 5, "a single cut inside a quadrant: {out}"); + assert_eq!(profiles(&s).len(), 1); + assert!((profiles(&s)[0] - area).abs() < 1e-6); +} + +#[test] +fn explicit_break_rejects_bad_points_atomically_and_merges_duplicates() { + for freeform in [false, true] { + let mut s = new_sketch(); + let out = if freeform { + run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false})) + } else { + run(&mut s, "sketch.line", json!({"points": [[0,0], [20,0]], "connect": false})) + }; + let id = ids(&out["curves"])[0].clone(); + let before = serde_json::to_value(sketch(&s)).unwrap(); + for points in [json!([]), json!("wrong"), json!([[8, 4], ["bad", 0]]), json!([[0, 0]]), json!([[1e20, 0]]), json!(vec![[10, 5]; 1001])] { + assert!(s.execute("sketch.break", &json!({"curve": id, "points": points})).is_err()); + assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before); + } + let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,7], [10,7]]})); + assert_eq!(ids(&out["curves"]).len(), 2, "{out}"); + } +} + +#[test] +fn analytic_trim_and_break_use_freeform_cutters() { + let mut s = new_sketch(); + let line = ids(&run(&mut s, "sketch.line", json!({"points": [[-5,5], [25,5]], "connect": false}))["curves"])[0].clone(); + run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false})); + let out = run(&mut s, "sketch.break", json!({"curve": line, "at": [10,5]})); + assert_eq!(ids(&out["curves"]).len(), 3, "{out}"); + let sk = sketch(&s); + for id in ids(&out["curves"]) { + let shape = sk.shape(sk.curve_index(&id).unwrap()).unwrap(); + assert!(matches!(shape, solvecraft_sketch::Shape::Line { a, b } if (a.y - 5.0).abs() < 1e-6 && (b.y - 5.0).abs() < 1e-6)); + } + + let mut s = new_sketch(); + let circle = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 5}))["curves"])[0].clone(); + run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [8,0], "minor": [0,3]})); + run(&mut s, "sketch.trim", json!({"curve": circle, "at": [5,0]})); + let sk = sketch(&s); + let ci = sk.curve_index(&circle).unwrap(); + assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. })); + assert!(sk.shape(ci).unwrap().dist(Vec2::new(-5.0, 0.0)) < 1e-6); +} + #[test] fn trim_extend_break() { let mut s = new_sketch(); @@ -219,13 +315,9 @@ fn ellipse_splines_conic() { run(&mut s, "sketch.constraint.coincident", json!({"a": "q", "b": eid})); let sk = sketch(&s); let q = sk.point(sk.point_index("q").unwrap()).unwrap(); - let poly = sk.polyline(sk.curve_index(&eid).unwrap()); - let d = poly.windows(2).map(|w| { - let (a, b) = (w[0], w[1]); - let t = ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0); - q.dist(a + (b - a) * t) - }); - assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}"); + let exact = sk.exact_segs(sk.curve_index(&eid).unwrap()).unwrap(); + let distance = exact.iter().map(|seg| q.dist(seg.point_at(seg.closest_param(q)))).fold(f64::INFINITY, f64::min); + assert!(distance < 1e-6, "on the exact ellipse: {q:?}"); // Fit spline closed by a line: one profile. let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]})); let sid = ids(&sp["curves"])[0].clone(); diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index dd0ab8fe..67501f4d 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -345,7 +345,13 @@ impl Drop for Nested { pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { if id == "timeline.redefine" { let cmd = params.get("command")?.as_str()?; - let inner = to_local(s, cmd, params.get("params")?)?; + let key = params.get("feature").and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|id| id.to_string())))?; + let feature = s.doc.find_feature(&key)?; + // Edit Feature authors in the existing feature's frame, even when another + // component is active. Body-relative picks still use the picked body's frame. + let mut context = s.scratch(); + context.active_component = feature.component; + let inner = to_local(&context, cmd, params.get("params")?)?; let mut out = params.clone(); out["params"] = inner; return Some(out); diff --git a/crates/engine/src/frames_tests.rs b/crates/engine/src/frames_tests.rs index 751efdf1..220ab277 100644 --- a/crates/engine/src/frames_tests.rs +++ b/crates/engine/src/frames_tests.rs @@ -77,10 +77,14 @@ fn world_picks_map_into_the_component() { assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material"); // A sketch on the moved top face sits at world z = 10 over the moved block. run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"})); - run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2})); + run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2, "id": "PadCircle"})); + let sketch = run(&mut s, "sketch.inspect", json!({"sketch": "Pad"})); + let center = sketch["curves"].as_array().unwrap().iter().find(|c| c["id"] == "PadCircle").unwrap()["center"].as_str().unwrap().to_string(); run(&mut s, "sketch.finish", json!({})); - let pad = sketch_point_world(&mut s, "Pad", "p1"); - assert!((pad.z - 10.0).abs() < 1e-6 && pad.x > 50.0, "{pad:?}"); + // A projected boundary's first point is an enumeration detail; check the circle + // that was actually authored in the moved component's local sketch coordinates. + let pad = sketch_point_world(&mut s, "Pad", ¢er); + assert!(near(pad, Vec3::new(70.0, 10.0, 10.0)), "{pad:?}"); let v1 = volume(&mut s); let h = run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5})); assert!(volume(&mut s) < v1 - 1.0, "the hole cut material: {h}"); diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index e98a3210..4b57d605 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -19,6 +19,144 @@ fn unsupported(msg: &str) -> KernelError { /// Samples per edge for the contact curves and the blend surface. const SAMPLES: usize = 24; +const MAX_SAMPLES: usize = 369; +const MAX_CHAIN_SAMPLES: usize = 100_000; + +struct Fit { + surface: mt::Surface, + contacts: [mt::Curve; 2], + /// Parameter range belonging to this edge (the fit includes neighbouring sections). + range: (f64, f64), +} + +struct Guide { + sections: Vec
, + lengths: Vec, +} + +impl Guide { + fn new(sections: Vec
) -> Result { + let mut lengths = vec![0.0]; + let mut total = 0.0; + for pair in sections.windows(2) { + let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue }; + total += a.c.dist(b.c); + lengths.push(total); + } + if !total.is_finite() || total <= 1e-12 { + return Err(unsupported("degenerate rolling-ball spine")); + } + Ok(Self { sections, lengths }) + } + + fn anchor(&self, along: f64) -> Option<(Vec3, Vec3)> { + if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) { + return None; + } + let distance = self.lengths.last()? * along; + let hi = self.lengths.partition_point(|length| *length < distance).clamp(1, self.lengths.len().checked_sub(1)?); + let (a, b) = (self.sections.get(hi - 1)?, self.sections.get(hi)?); + let (lo, hi_length) = (self.lengths.get(hi - 1)?, self.lengths.get(hi)?); + let span = hi_length - lo; + if span <= 1e-12 { + return None; + } + let fraction = (distance - lo) / span; + Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?)) + } +} + +/// One interpolation for both the face and its boundary curves. Fitting the contacts +/// independently gives different end conditions and leaves gaps between the same faces. +fn fit_sections(sections: &[Vec
], k: usize, closed: bool, round: bool) -> Result { + use mt::Cut; + let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}")); + let n = sections.len(); + let list = sections.get(k).ok_or_else(|| fail("section"))?; + let before = usize::from(closed || k > 0) * 2; + let mut ext = Vec::with_capacity(list.len() + 4); + if before > 0 { + let prev = sections.get((k + n - 1) % n).ok_or_else(|| fail("section"))?; + ext.extend(prev.iter().rev().skip(1).take(2).rev().copied()); + } + ext.extend(list.iter().copied()); + if closed || k + 1 < n { + let next = sections.get((k + 1) % n).ok_or_else(|| fail("section"))?; + ext.extend(next.iter().skip(1).take(2).copied()); + } + let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::>().ok_or_else(|| fail("section"))?; + let mut rows = Vec::with_capacity(3); + let mut vknots = None; + for j in 0..3 { + let xyz: Vec = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.x, c.y, c.z)).collect(); + let ww: Vec = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.w, 0.0, 0.0)).collect(); + let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?; + let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?; + vknots = Some(bx.knot_vec().clone()); + rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect::>()); + } + let vk = vknots.ok_or_else(|| fail("knots"))?; + let den = ext.len().saturating_sub(1) as f64; + if den <= 0.0 { + return Err(fail("empty sections")); + } + let range = (before as f64 / den, (before + list.len().saturating_sub(1)) as f64 / den); + let contact = |row: usize| -> Result { + let points = rows.get(row).cloned().ok_or_else(|| fail("contact row"))?; + let bs = mt::BSplineCurve::try_new(vk.clone(), points).map_err(|e| fail(&e.to_string()))?; + let mut curve = mt::NurbsCurve::new(bs); + if range.0 > 0.0 { + curve = curve.cut(range.0); + } + if range.1 < 1.0 { + let _ = curve.cut(range.1); + } + Ok(mt::Curve::NurbsCurve(curve)) + }; + let contacts = [contact(0)?, contact(2)?]; + let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), vk), rows).map_err(|e| fail(&e.to_string()))?; + Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range }) +} + +/// Compare the fitted strip with a solved circular section between interpolation nodes. +/// This checks the supporting contacts as well as the middle of the fillet band. +fn section_fits(fit: &Fit, section: &Section, along: f64, round: bool, sigma: f64, tolerance: f64) -> bool { + use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + let Some(ctrl) = section_controls(section, round) else { return false }; + let v = fit.range.0 + (fit.range.1 - fit.range.0) * along; + for u in [0.0, 0.25, 0.5, 0.75, 1.0] { + let [a, b, c] = ctrl; + let h = a * (1.0 - u) * (1.0 - u) + b * (2.0 * u * (1.0 - u)) + c * u * u; + if !h.w.is_finite() || h.w <= 1e-12 { + return false; + } + let exact = Vec3::new(h.x / h.w, h.y / h.w, h.z / h.w); + // The input intersection edge may use piecewise leader-curve parameters. A + // parameter-speed change is not a geometric error: compare nearest points, with + // contacts constrained to the corresponding boundary and all probes to the strip. + let (probe_u, probe_v) = if u == 0.0 || u == 1.0 { + let Some(contact) = fit.contacts.get(usize::from(u == 1.0)) else { return false }; + let Some(parameter) = contact.search_nearest_parameter(p3(exact), v, 32) else { return false }; + (u, parameter.clamp(fit.range.0, fit.range.1)) + } else { + let Some((pu, pv)) = fit.surface.search_nearest_parameter(p3(exact), Some((u, v)), 32) else { return false }; + (pu.clamp(0.0, 1.0), pv.clamp(fit.range.0, fit.range.1)) + }; + let actual = from_p3(fit.surface.subs(probe_u, probe_v)); + if !actual.is_finite() || actual.dist(exact) > tolerance { + return false; + } + if round && (u == 0.0 || u == 1.0) { + let Some(want) = ((section.c - exact) * sigma).normalized() else { return false }; + let normal = fit.surface.normal(probe_u, probe_v); + let Some(normal) = Vec3::new(normal.x, normal.y, normal.z).normalized() else { return false }; + if normal.dot(want).abs() < 0.5f64.to_radians().cos() { + return false; + } + } + } + true +} /// One cross-section of the blend: ball centre and its contacts with side 1 and side 2. #[derive(Clone, Copy, Debug)] @@ -102,6 +240,16 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> { /// Blend the closed chain of edges (ids in any order) with radius `r`. pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result { + curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES) +} + +fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result { + if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) { + return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into())); + } + if !(4..=MAX_SAMPLES).contains(&max_samples) { + return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into())); + } let tol = (size * 1e-7).max(1e-9); let shells = solid.boundaries(); let (si, shell) = shells @@ -303,16 +451,85 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } vsec.push(s); } - let mut esec: Vec> = Vec::with_capacity(n); - for (k, e) in chain.iter().enumerate() { - let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?]; - for j in 1..SAMPLES - 1 { - let (p, t) = tangent_at(e, j as f64 / (SAMPLES - 1) as f64).ok_or_else(|| KernelError::Failed("tangent".into()))?; - list.push(solve(&s1, &s2, p, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?); + // Uniform refinement preserves a common spacing across neighbouring patches. Work is + // capped: a difficult strip returns an error instead of accepting an inaccurate fit. + let fit_tolerance = (size * 1e-5).clamp(1e-5, 0.001); + // At a seam the contact lies on the seam plane, which need not be square to the + // original edge. Its ball centre therefore need not lie in the edge's normal plane. + // Interpolate that plane's displacement so the interior sections tend to the exact + // seam sections, rather than leaving a finite jump as sampling gets finer. + let offsets: Vec<(f64, f64)> = chain + .iter() + .enumerate() + .map(|(k, edge)| { + let (p0, t0) = tangent_at(edge, 0.0).ok_or_else(|| KernelError::Failed("tangent".into()))?; + let (p1, t1) = tangent_at(edge, 1.0).ok_or_else(|| KernelError::Failed("tangent".into()))?; + let start = vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?; + let end = vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?; + Ok(((start.c - p0).dot(t0), (end.c - p1).dot(t1))) + }) + .collect::>()?; + let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result
{ + let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?; + let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?; + let anchor = p + t * (start * (1.0 - along) + end * along); + solve(&s1, &s2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge")) + }; + let mut samples = SAMPLES.min(max_samples); + let (esec, fits) = loop { + if n.checked_mul(samples).is_none_or(|count| count > MAX_CHAIN_SAMPLES) { + return Err(unsupported("curve blend exceeds the section fitting limit")); } - list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?); - esec.push(list); - } + let mut guides = Vec::with_capacity(n); + for (k, e) in chain.iter().enumerate() { + let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?]; + for j in 1..samples - 1 { + list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?); + } + list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?); + guides.push(Guide::new(list)?); + } + // Intersection curves inherit their leader's piecewise parameter speed. Fitting + // those parameters creates oscillations even on a geometrically smooth spine. + // Resample by distance along the rolling centres, projecting each interpolated + // centre back onto the exact two offset surfaces before it becomes a section. + let uniform_section = |k: usize, along: f64| -> Result
{ + let guide = guides.get(k).ok_or_else(|| KernelError::Failed("spine guide".into()))?; + if along == 0.0 { + return guide.sections.first().copied().ok_or_else(|| KernelError::Failed("section".into())); + } + if along == 1.0 { + return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into())); + } + let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?; + solve(&s1, &s2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine")) + }; + let esec: Vec> = (0..n) + .map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::>>()) + .collect::>()?; + let fits = crate::guard("fit curve blend", || (0..n).map(|k| fit_sections(&esec, k, closed, round)).collect::>>())?; + let mut accurate = true; + 'edges: for k in 0..n { + let fit = fits.get(k).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?; + for j in 0..samples - 1 { + for fraction in [0.25, 0.5, 0.75] { + let along = (j as f64 + fraction) / (samples - 1) as f64; + let section = uniform_section(k, along)?; + if !crate::guard("check curve blend fit", || Ok(section_fits(fit, §ion, along, round, sigma, fit_tolerance)))? { + accurate = false; + break 'edges; + } + } + } + } + if accurate { + break (esec, fits); + } + if samples >= max_samples { + return Err(KernelError::Failed("curve blend: could not fit the rolling-ball surface within the accuracy and section limits".into())); + } + samples = (2 * samples - 1).min(max_samples); + }; crate::guard("curve blend", || { let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}")); // Vertices and arcs at the chain's vertices. @@ -334,47 +551,18 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let mut c2: Vec = Vec::with_capacity(n); let mut blends: Vec = Vec::with_capacity(n); for (k, list) in esec.iter().enumerate() { - let q1: Vec = list.iter().map(|s| s.q1).collect(); - let q2: Vec = list.iter().map(|s| s.q2).collect(); let k1 = (k + 1) % nv; let (a1, b1, a2, b2) = (v1.get(k), v1.get(k1), v2.get(k), v2.get(k1)); let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) }; - let cv1 = crate::build::interpolate_cubic(&q1).ok_or_else(|| fail("contact curve"))?; - let cv2 = crate::build::interpolate_cubic(&q2).ok_or_else(|| fail("contact curve"))?; - let e1 = mt::Edge::new(a1, b1, mt::Curve::BSplineCurve(cv1)); - let e2 = mt::Edge::new(a2, b2, mt::Curve::BSplineCurve(cv2)); - // The blend surface: each control row interpolated along the edge (homogeneous), - // through two sections of each neighbouring edge too, so that neighbouring blend - // faces meet smoothly. - let mut ext: Vec
= Vec::with_capacity(list.len() + 4); - if closed || k > 0 { - let prev = esec.get((k + n - 1) % n).ok_or_else(|| fail("section"))?; - ext.extend(prev.iter().rev().skip(1).take(2).rev().copied()); - } - ext.extend(list.iter().copied()); - if closed || k + 1 < n { - let next = esec.get((k + 1) % n).ok_or_else(|| fail("section"))?; - ext.extend(next.iter().skip(1).take(2).copied()); - } - let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::>().ok_or_else(|| fail("section"))?; - let mut rows: Vec> = Vec::with_capacity(3); - let mut vknots = None; - for j in 0..3 { - let xyz: Vec = ctrl.iter().map(|c| Vec3::new(c[j].x, c[j].y, c[j].z)).collect(); - let ww: Vec = ctrl.iter().map(|c| Vec3::new(c[j].w, 0.0, 0.0)).collect(); - let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?; - let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?; - vknots = Some(bx.knot_vec().clone()); - rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect()); - } - let vk = vknots.ok_or_else(|| fail("knots"))?; - let uk = mt::KnotVec::bezier_knot(2); - let bs = mt::BSplineSurface::try_new((uk, vk), rows).map_err(|e| fail(&e.to_string()))?; - let mut surface = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)); + let fit = fits.get(k).ok_or_else(|| fail("fit"))?; + let [cv1, cv2] = &fit.contacts; + let e1 = mt::Edge::new(a1, b1, cv1.clone()); + let e2 = mt::Edge::new(a2, b2, cv2.clone()); + let mut surface = fit.surface.clone(); // Outward: toward the ball centre on a concave edge, away on a convex one. { use mt::{ParametricSurface3D, SearchNearestParameter}; - let s = list.get(SAMPLES / 2).ok_or_else(|| fail("section"))?; + let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?; let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r; if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) { let nn = surface.normal(u, v); @@ -527,3 +715,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}"))) }) } + +#[cfg(test)] +#[path = "curveblend_tests.rs"] +mod tests; diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs new file mode 100644 index 00000000..941168a3 --- /dev/null +++ b/crates/kernel/src/curveblend_tests.rs @@ -0,0 +1,194 @@ +//! Independent analytic reference: the centre of a ball tangent to two perpendicular +//! cylinders lies on the intersection of their radius-offset cylinders. + +use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + +use super::*; +use crate::{Body, BoolOp, boolean, cylinder, fillet}; + +fn tee(scale: f64, origin: Vec3) -> Body { + let main = cylinder(origin + Vec3::new(-40.0, 0.0, 0.0) * scale, Vec3::X, 15.0 * scale, 80.0 * scale).unwrap(); + let branch = cylinder(origin, Vec3::Z, 10.0 * scale, 35.0 * scale).unwrap(); + boolean(&main, &branch, BoolOp::Union).unwrap().unwrap() +} + +fn analytic_section(theta: f64, scale: f64, origin: Vec3) -> (Vec3, Vec3, Vec3) { + let centre = Vec3::new(13.0 * theta.cos(), 13.0 * theta.sin(), (324.0 - 169.0 * theta.sin().powi(2)).sqrt()); + let main = Vec3::new(centre.x, centre.y * 15.0 / 18.0, centre.z * 15.0 / 18.0); + let branch = Vec3::new(centre.x * 10.0 / 13.0, centre.y * 10.0 / 13.0, centre.z); + (origin + centre * scale, origin + main * scale, origin + branch * scale) +} + +fn nearest_blend(surfaces: &[mt::Surface], point: Vec3) -> (f64, Vec3) { + surfaces + .iter() + .filter_map(|s| { + let (u, v) = s.search_nearest_parameter(p3(point), None, 100)?; + let actual = from_p3(s.subs(u, v)); + let n = s.normal(u, v); + Some((actual.dist(point), Vec3::new(n.x, n.y, n.z).normalized()?)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .unwrap() +} + +fn distance_to_analytic_arc(theta: f64, point: Vec3) -> f64 { + let (centre, main, branch) = analytic_section(theta, 1.0, Vec3::ZERO); + let a = (main - centre).normalized().unwrap(); + let b = (branch - centre).normalized().unwrap(); + let plane = a.cross(b).normalized().unwrap(); + let radial = point - centre; + let projected = radial - plane * radial.dot(plane); + let direction = projected.normalized().unwrap(); + let angle = a.dot(b).clamp(-1.0, 1.0).acos(); + let from_main = a.dot(direction).clamp(-1.0, 1.0).acos(); + let from_branch = b.dot(direction).clamp(-1.0, 1.0).acos(); + if (from_main + from_branch - angle).abs() < 1e-7 { point.dist(centre + direction * 3.0) } else { point.dist(main).min(point.dist(branch)) } +} + +fn distance_to_analytic_band(point: Vec3) -> f64 { + // The arc's XY azimuth stays between its two contacts' azimuths. Their difference + // for this tee is less than 0.1 rad; bracket generously around the query azimuth. + let azimuth = point.y.atan2(point.x); + let (mut lo, mut hi) = (azimuth - 0.3, azimuth + 0.3); + for _ in 0..55 { + let a = lo + (hi - lo) * 0.38196601125; + let b = lo + (hi - lo) * 0.61803398875; + if distance_to_analytic_arc(a, point) < distance_to_analytic_arc(b, point) { + hi = b; + } else { + lo = a; + } + } + distance_to_analytic_arc((lo + hi) * 0.5, point) +} + +#[test] +fn fitted_contact_edges_coincide_with_their_surface_boundaries() { + let sections: Vec> = (0..4) + .map(|k| { + (0..SAMPLES) + .map(|i| { + let theta = std::f64::consts::FRAC_PI_2 * (k as f64 + i as f64 / (SAMPLES - 1) as f64); + let (c, q1, q2) = analytic_section(theta, 1.0, Vec3::ZERO); + Section { c, q1, q2 } + }) + .collect() + }) + .collect(); + for k in 0..sections.len() { + let fit = crate::guard("contact consistency", || fit_sections(§ions, k, true, true)).unwrap(); + for (side, curve) in fit.contacts.iter().enumerate() { + let (lo, hi) = curve.range_tuple(); + for i in 0..=200 { + let parameter = lo + (hi - lo) * i as f64 / 200.0; + let edge = from_p3(curve.subs(parameter)); + let surface = from_p3(fit.surface.subs(side as f64, parameter)); + assert!(edge.dist(surface) < 1e-9, "contact-to-surface gap {}", edge.dist(surface)); + } + } + } +} + +#[test] +fn rolling_ball_matches_analytic_tee_and_contacts() { + for (scale, origin) in [(1.0, Vec3::ZERO), (0.25, Vec3::new(23.0, -17.0, 9.0))] { + let body = tee(scale, origin); + let point = origin + Vec3::new(0.0, 10.0, 125f64.sqrt()) * scale; + let blend = fillet(&body, &[point], 3.0 * scale).unwrap(); + assert!(blend.validity().is_empty(), "{:?}", blend.validity()); + let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect(); + assert!(!surfaces.is_empty()); + let mut distance: f64 = 0.0; + let mut tangent_angle: f64 = 0.0; + for i in 0..96 { + // Offset the angular samples from patch seams; contact endpoints are tested below. + let theta = std::f64::consts::TAU * (i as f64 + 0.37) / 96.0; + let (centre, main, branch) = analytic_section(theta, scale, origin); + let a = (main - centre).normalized().unwrap(); + let b = (branch - centre).normalized().unwrap(); + let angle = a.dot(b).clamp(-1.0, 1.0).acos(); + for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] { + // Spherical linear interpolation of the radial unit vectors, independent of + // the production rational control-point construction and fitting parameters. + let radial = (a * ((1.0 - fraction) * angle).sin() + b * (fraction * angle).sin()) / angle.sin(); + let exact = centre + radial * (3.0 * scale); + let (error, normal) = nearest_blend(&surfaces, exact); + distance = distance.max(error); + if fraction == 0.0 || fraction == 1.0 { + let want = (centre - exact).normalized().unwrap(); + tangent_angle = tangent_angle.max(normal.dot(want).abs().min(1.0).acos().to_degrees()); + } + } + } + assert!(distance <= 0.005, "scale {scale}: analytic surface deviation {distance} mm"); + assert!(tangent_angle <= 0.5, "scale {scale}: contact normals differ by {tangent_angle} degrees"); + + // The reverse direction uses points on the actual trimmed face mesh, so an + // untrimmed neighbouring surface cannot conceal an overshooting blend patch. + let mesh = blend.tessellate_with(0.002, true).unwrap(); + let mut reverse_distance: f64 = 0.0; + let mut probes = 0; + for point in mesh.positions.iter().step_by(19) { + let p = (*point - origin) / scale; + if (p.x * p.x + p.y * p.y).sqrt() <= 10.000001 + || (p.y * p.y + p.z * p.z).sqrt() <= 15.000001 + || !(10.0..20.0).contains(&p.z) + || p.x.abs() > 16.0 + { + continue; + } + probes += 1; + reverse_distance = reverse_distance.max(distance_to_analytic_band(p) * scale); + } + assert!(probes > 300, "only {probes} trimmed surface points checked"); + assert!(reverse_distance <= 0.005, "scale {scale}: reverse analytic deviation {reverse_distance} mm"); + eprintln!("scale {scale}: surface {distance:.9} mm, reverse {reverse_distance:.9} mm, contact normals {tangent_angle:.6} degrees"); + + let mut contact_count = 0; + for edge in Body::unique_edges(&blend.solid) { + let curve = edge.oriented_curve(); + let (lo, hi) = curve.range_tuple(); + let endpoints = [from_p3(curve.subs(lo)), from_p3(curve.subs(hi))].map(|p| (p - origin) / scale); + if endpoints.iter().any(|p| !(9.0..20.0).contains(&p.z) || p.x.abs() > 16.0) { + continue; + } + let branch = endpoints.iter().all(|p| ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 1e-5); + let main = endpoints.iter().all(|p| ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 1e-5); + if !branch && !main { + continue; + } + contact_count += 1; + for i in 0..=200 { + let p = (from_p3(curve.subs(lo + (hi - lo) * i as f64 / 200.0)) - origin) / scale; + let radius_error = if branch { ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() } else { ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() }; + assert!(radius_error * scale <= 0.002, "supporting cylinder deviation {} mm", radius_error * scale); + } + } + assert!(contact_count >= 4, "only {contact_count} contacts checked"); + } +} + +#[test] +fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() { + let body = tee(1.0, Vec3::ZERO); + let solid = body.deep_copy(); + let ids: Vec<_> = Body::unique_edges(&solid) + .iter() + .filter(|edge| { + let curve = edge.oriented_curve(); + let (lo, hi) = curve.range_tuple(); + let p = from_p3(curve.subs((lo + hi) * 0.5)); + p.z > 10.0 && ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 0.01 && ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 0.01 + }) + .map(mt::Edge::id) + .collect(); + assert!(!ids.is_empty()); + let before = crate::step_export(&[&body], "unchanged").unwrap(); + let input_before = crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(); + // Four sections deliberately undersample the saddle; production starts at 24. + let error = crate::guard("limited rolling ball", || curve_blend_with_limit(&solid, &ids, body.size(), 3.0, true, 4)).unwrap_err(); + assert!(error.to_string().contains("accuracy and section limits"), "{error}"); + assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before); + assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before); +} diff --git a/crates/kernel/src/step_in/geom.rs b/crates/kernel/src/step_in/geom.rs index af767dfb..52e4d8f0 100644 --- a/crates/kernel/src/step_in/geom.rs +++ b/crates/kernel/src/step_in/geom.rs @@ -1,7 +1,9 @@ //! STEP geometry (ISO 10303-42 entities) to kernel curves and surfaces. //! //! Points, placements, lines, conics, B-splines (plain and rational), elementary surfaces and -//! swept surfaces. Edge curves are trimmed to their vertices here, so every kernel edge runs +//! swept surfaces, with exact offsets of planes, cylinders, cones, spheres and regular tori. +//! Free-form offsets and offsets through collapsed radii remain explicit import warnings. +//! Edge curves are trimmed to their vertices here, so every kernel edge runs //! exactly from its start vertex to its end vertex. Lengths are scaled to millimetres while //! reading. @@ -761,6 +763,59 @@ pub(crate) fn bspline_surface(cx: &Ctx, e: &Entity) -> R { } } +/// Resolve exact analytic offsets. Each intermediate offset is checked: an invalid inner +/// offset must not become valid merely because an outer offset cancels its distance. +/// Entity attributes follow ISO 10303-42 (`basis_surface`, `distance`, `self_intersect`). +fn offset_basis(cx: &Ctx, id: u64, depth: usize) -> R<(u64, f64)> { + if depth > MAX_GEOM_DEPTH { + return Err("surface definitions nested too deeply".into()); + } + let e = entity(cx, id)?; + if e.name() != "OFFSET_SURFACE" { + return Ok((id, 0.0)); + } + let p = e.params(); + let basis = p.get(1).and_then(Param::as_ref_id).ok_or("offset surface without basis")?; + let distance = length(cx, p.get(2), "offset distance")?; + if !matches!(p.get(3).and_then(Param::as_enum), Some("T" | "F" | "U")) { + return Err("offset surface without a logical self_intersect flag".into()); + } + let (basis, inner) = offset_basis(cx, basis, depth + 1)?; + let offset = inner + distance; + if !offset.is_finite() || offset.abs() >= MAX_COORD { + return Err("offset distance out of range after unit conversion".into()); + } + let e = entity(cx, basis)?; + let p = e.params(); + match e.name() { + "PLANE" => {} + "CYLINDRICAL_SURFACE" | "SPHERICAL_SURFACE" => { + let radius = length(cx, p.get(2), "radius")?; + if radius <= 0.0 || !(1e-9..MAX_COORD).contains(&(radius + offset)) { + return Err("offset surface collapses or reverses its radius".into()); + } + } + "CONICAL_SURFACE" => { + let radius = length(cx, p.get(2), "radius")?; + let angle = num(p.get(3), "semi angle")? * cx.ang; + if radius < 0.0 || !(angle.abs() > 1e-9 && angle.abs() < FRAC_PI_2 - 1e-9) || !(0.0..MAX_COORD).contains(&(radius + offset / angle.cos())) + { + return Err("offset cone has an invalid reference radius or angle".into()); + } + } + "TOROIDAL_SURFACE" => { + let major = length(cx, p.get(2), "major radius")?; + let minor = length(cx, p.get(3), "minor radius")?; + if minor <= 0.0 || major <= minor || major >= MAX_COORD || !(1e-9..major).contains(&(minor + offset)) { + return Err("offset torus collapses or becomes self-intersecting".into()); + } + } + _ if offset == 0.0 => {} + n => return Err(format!("unsupported OFFSET_SURFACE basis surface {n}")), + } + Ok((basis, offset)) +} + /// A STEP surface entity as a kernel surface whose normal is the STEP surface normal. /// `extent` holds points of the face boundary (bounds the analytic surfaces' parameter ranges). /// `sense` false gives the opposite normal (a face with `same_sense = .F.`). @@ -776,6 +831,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin if depth > MAX_GEOM_DEPTH { return Err("surface definitions nested too deeply".into()); } + let (id, offset) = offset_basis(cx, id, depth)?; let e = entity(cx, id)?; if e.is_complex() { return bspline_surface(cx, e).map(flip); @@ -785,11 +841,12 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin match e.name() { "PLANE" => { let f = placement()?; - Ok(flip(mt::Surface::Plane(mt::Plane::new(f.o, f.o + f.x, f.o + f.y)))) + let o = f.o + f.z * offset; + Ok(flip(mt::Surface::Plane(mt::Plane::new(o, o + f.x, o + f.y)))) } "CYLINDRICAL_SURFACE" => { let f = seam_frame(&placement()?, extent, seam); - let r = length(cx, p.get(2), "radius")?; + let r = length(cx, p.get(2), "radius")? + offset; if r <= 0.0 { return Err("cylinder radius must be positive".into()); } @@ -799,8 +856,9 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin } "CONICAL_SURFACE" => { let f = seam_frame(&placement()?, extent, seam); - let r = length(cx, p.get(2), "radius")?; + let basis_r = length(cx, p.get(2), "radius")?; let a = num(p.get(3), "semi angle")? * cx.ang; + let r = basis_r + offset / a.cos(); if r < 0.0 || !(a.abs() > 1e-9 && a.abs() < FRAC_PI_2 - 1e-9) { return Err("bad cone".into()); } @@ -821,7 +879,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin } "SPHERICAL_SURFACE" => { let f = placement()?; - let r = length(cx, p.get(2), "radius")?; + let r = length(cx, p.get(2), "radius")? + offset; if r <= 0.0 { return Err("sphere radius must be positive".into()); } @@ -844,7 +902,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin } "TOROIDAL_SURFACE" => { let f = placement()?; - let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")?); + let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")? + offset); if small <= 0.0 || big < 0.0 { return Err("bad torus radii".into()); } diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index cb8e9f2c..a8bcd8fc 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -76,6 +76,106 @@ fn imported_bodies_take_features() { const HEAD: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('t','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n"; const TAIL: &str = "ENDSEC;\nEND-ISO-10303-21;\n"; +/// Original fixtures: elementary basis surfaces plus offsets, with no external CAD files. +fn offset_geometry(definition: &str, offset: &str, len: f64, sense: bool, points: &[mt::Point3]) -> crate::Result { + let text = format!( + "{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=AXIS2_PLACEMENT_3D('',#1,$,$);\n#3={definition};\n#4=OFFSET_SURFACE('',#3,{offset},.U.);\n{TAIL}" + ); + let ex = p21::parse(&text).unwrap(); + let cx = Ctx { ex: &ex, len, ang: 1.0, tol: 1e-6 }; + crate::guard("test offset surface", || geom::surface(&cx, 4, points, &[], sense, 0).map_err(KernelError::Failed)) +} + +#[test] +fn offset_surface_analytic_geometry_and_orientation() { + use mt::{InnerSpace, MetricSpace, ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + let angle: f64 = 0.4; + let cases = [ + ("PLANE('',#2)".to_string(), mt::Point3::new(2.0, 3.0, 1.0), mt::Vector3::unit_z()), + ("CYLINDRICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 2.0), mt::Vector3::unit_x()), + ("SPHERICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 0.0), mt::Vector3::unit_x()), + ("TOROIDAL_SURFACE('',#2,10.,2.)".to_string(), mt::Point3::new(13.0, 0.0, 0.0), mt::Vector3::unit_x()), + ( + format!("CONICAL_SURFACE('',#2,5.,{angle})"), + mt::Point3::new(5.0 + 1.0 / angle.cos() + 2.0 * angle.tan(), 0.0, 2.0), + mt::Vector3::new(angle.cos(), 0.0, -angle.sin()), + ), + ]; + for (definition, point, normal) in cases { + for sense in [true, false] { + let extent = [point, point + mt::Vector3::unit_z()]; + let surface = offset_geometry(&definition, "1.", 1.0, sense, &extent).unwrap(); + let uv = surface.search_nearest_parameter(point, None, 100).unwrap(); + assert!(surface.subs(uv.0, uv.1).distance(point) < 1e-6, "{definition}"); + assert!(surface.normal(uv.0, uv.1).dot(normal) * if sense { 1.0 } else { -1.0 } > 0.99, "{definition}, sense {sense}"); + } + } + let p = mt::Point3::new(40.0, 0.0, 2.0); + let surface = offset_geometry("CYLINDRICAL_SURFACE('',#2,5.)", "-1.", 10.0, true, &[p, p + mt::Vector3::unit_z()]).unwrap(); + let uv = surface.search_nearest_parameter(p, None, 100).unwrap(); + assert!(surface.subs(uv.0, uv.1).distance(p) < 1e-6); +} + +#[test] +fn offset_surfaces_nested_and_hostile() { + let points = [mt::Point3::new(0.0, 0.0, 0.0), mt::Point3::new(0.0, 0.0, 1.0)]; + for (basis, offset) in [ + ("CYLINDRICAL_SURFACE('',#2,5.)", "-5."), + ("SPHERICAL_SURFACE('',#2,5.)", "-6."), + ("TOROIDAL_SURFACE('',#2,10.,2.)", "8."), + ("TOROIDAL_SURFACE('',#2,10.,2.)", "-2."), + ("CONICAL_SURFACE('',#2,5.,0.4)", "-10."), + ("PLANE('',#2)", "1.E100"), + ("PLANE('',#2)", "$"), + ] { + assert!(offset_geometry(basis, offset, 1.0, true, &points).is_err(), "{basis}, {offset}"); + } + let text = format!("{HEAD}#1=OFFSET_SURFACE('',#1,1.,.F.);{TAIL}"); + let ex = p21::parse(&text).unwrap(); + let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 }; + assert!(geom::surface(&cx, 1, &points, &[], true, 0).unwrap_err().contains("nested too deeply")); + let text = format!( + "{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));#2=AXIS2_PLACEMENT_3D('',#1,$,$);#3=CYLINDRICAL_SURFACE('',#2,5.);#4=OFFSET_SURFACE('',#3,-6.,.F.);#5=OFFSET_SURFACE('',#4,6.,.F.);{TAIL}" + ); + let ex = p21::parse(&text).unwrap(); + let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 }; + assert!(geom::surface(&cx, 5, &points, &[], true, 0).is_err()); +} + +#[test] +fn offset_surface_cube_import_is_closed_and_editable() { + // Move each basis plane half a file unit inward; nested quarter-unit offsets + // bring its supporting surface back to the original unit cube boundary. + let mut entities = cube_entities(); + let origins = [(0.0, 0.0, 0.5), (0.0, 0.0, 0.5), (0.0, 0.5, 0.0), (0.5, 0.0, 0.0), (1.0, 0.5, 0.0), (0.5, 1.0, 0.0)]; + for (f, (x, y, z)) in origins.into_iter().enumerate() { + let base = 400 + f * 20; + let extra = 1000 + f * 10; + entities = entities + .replace(&format!("#{}=PLANE('',#{});", base + 10, base + 9), &format!("#{}=OFFSET_SURFACE('',#{},0.25,.F.);", base + 10, extra + 3)); + entities += &format!( + "#{extra}=CARTESIAN_POINT('',({x},{y},{z}));\n#{}=AXIS2_PLACEMENT_3D('',#{extra},#{},$);\n#{}=PLANE('',#{});\n#{}=OFFSET_SURFACE('',#{},0.25,.F.);\n", + extra + 1, + base + 8, + extra + 2, + extra + 1, + extra + 3, + extra + 2 + ); + } + entities += &context(10, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))"); + entities += "#9=ADVANCED_BREP_SHAPE_REPRESENTATION('Offsets',(#101),#10);\n"; + let import = step_import(&format!("{HEAD}{entities}{TAIL}")).unwrap(); + assert!(import.warnings.is_empty(), "{:?}", import.warnings); + assert!(import.bodies[0].closed); + assert_eq!(import.bodies[0].file_faces, 6); + let body = &import.bodies[0].body; + assert!(rel(measure(body).unwrap().volume, 1000.0) < 1e-6); + let cut = cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap(); + let result = boolean(body, &cut, BoolOp::Cut).unwrap().unwrap(); + assert!(rel(measure(&result).unwrap().volume, 1000.0 - 40.0 * std::f64::consts::PI) < 1e-3); +} + /// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items. fn cube_entities() -> String { let mut s = String::new(); diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index c02ce3da..088b7b54 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -300,6 +300,13 @@ fn same_geometry(a: &[(LCurve, Vec)], b: &[(LCurve, Vec)]) -> bool { if a.len() != b.len() { return false; } + // Unchanged ordered links are common; avoid a quadratic permutation scan. + if a.iter().zip(b).all(|(x, y)| matches(x, y)) { + return true; + } + if a.len() > 512 { + return false; + } let mut used = vec![false; b.len()]; a.iter().all(|x| { let hit = b.iter().enumerate().find(|(j, y)| !used.get(*j).copied().unwrap_or(true) && matches(x, y)).map(|(j, _)| j); @@ -330,6 +337,48 @@ fn curve_points(c: &LCurve) -> Vec { } } +/// Incidence roles distinguish nearby arc centres, endpoints and hole centres during a +/// resize. Spatial proximity alone can select a different kind of point in a dense rim. +fn point_roles(curves: &[LCurve], point_count: usize) -> Vec<[usize; 10]> { + let mut roles = vec![[0; 10]; point_count]; + let mut add = |point: usize, role: usize| { + if let Some(count) = roles.get_mut(point).and_then(|r| r.get_mut(role)) { + *count += 1; + } + }; + for curve in curves { + match *curve { + LCurve::Line(a, b) => { + add(a, 0); + add(b, 0); + } + LCurve::Circle(c, _) => add(c, 1), + LCurve::Arc(c, a, b) => { + add(c, 2); + add(a, 3); + add(b, 3); + } + LCurve::Conic(a, b, x, _) => { + add(a, 4); + add(b, 4); + add(x, 5); + } + LCurve::Ellipse(c, m, _) => { + add(c, 6); + add(m, 7); + } + LCurve::Spline(a, b, first, count, ..) => { + add(a, 8); + add(b, 8); + for i in first..first + count { + add(i, 9); + } + } + } + } + roles +} + impl Sketch { pub fn link(&self, id: &str) -> Option<&Link> { self.links.iter().find(|l| l.id == id) @@ -539,26 +588,6 @@ impl Sketch { if let Some(l) = self.links.iter_mut().find(|l| l.id == id) { l.lost = false; } - if same { - for (k, pi) in points.iter().enumerate() { - let p = order.get(k).and_then(|li| lay.pts.get(*li)).copied(); - if let (Some(p), Some(sp)) = (p, self.points.get_mut(*pi)) { - sp.pos = p; - } - } - for (ci, c) in curves.iter().zip(&new) { - if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) { - *slot = *r; - } - if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) { - *rho = *r; - } - if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) { - *slot = *r; - } - } - return Ok(false); - } // The same geometry walked in another order or direction (e.g. a face loop re-read after // a rollback) is not a change: keep the entities, and the constraints on them. if let Some(cur) = &cur { @@ -588,6 +617,7 @@ impl Sketch { && points.len() == lay.pts.len() && cur.len() == lay.curves.len() && points.len() <= 512 + && cur.len() <= 512 { let old: Vec = points.iter().filter_map(|i| self.point(*i)).collect(); let normalized = |ps: &[Vec2]| -> Vec { @@ -596,9 +626,11 @@ impl Sketch { ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect() }; let (a, b) = (normalized(&old), normalized(&lay.pts)); + let (old_roles, fresh_roles) = (point_roles(cur, old.len()), point_roles(&lay.curves, lay.pts.len())); let mut mapping = Vec::new(); - for p in &a { - let mut candidates: Vec<(usize, f64)> = b.iter().enumerate().map(|(i, q)| (i, p.dist(*q))).collect(); + for (old_i, p) in a.iter().enumerate() { + let mut candidates: Vec<(usize, f64)> = + b.iter().enumerate().filter(|(i, _)| old_roles.get(old_i) == fresh_roles.get(*i)).map(|(i, q)| (i, p.dist(*q))).collect(); candidates.sort_by(|x, y| x.1.total_cmp(&y.1)); if let Some((i, d)) = candidates.first() && *d < 0.1 @@ -613,22 +645,59 @@ impl Sketch { let mut used = Vec::new(); let mut curve_map = Vec::new(); if mapping.len() == points.len() { - for c in cur { + for (curve_i, c) in cur.iter().enumerate() { let mapped: Option> = curve_points(c).iter().map(|i| mapping.get(*i).copied()).collect(); - let found = lay + let candidates: Vec = lay .curves .iter() .enumerate() - .find(|(i, fresh)| { - if used.contains(i) || !same_shape(c, fresh) { - return false; + .filter_map(|(i, fresh)| { + if !same_shape(c, fresh) { + return None; } let fresh_pts = curve_points(fresh); - mapped.as_ref().is_some_and(|p| { - *p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::>() == fresh_pts - }) + mapped + .as_ref() + .is_some_and(|p| { + *p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::>() == fresh_pts + }) + .then_some(i) }) - .map(|(i, _)| i); + .collect(); + let found = if candidates.len() == 1 { + candidates.first().copied() + } else if let LCurve::Circle(center, _) = c { + // Concentric circular boundaries share a point graph. Their radius + // order identifies the inner/outer rings through an upstream resize. + let mut previous: Vec<(usize, f64)> = cur + .iter() + .enumerate() + .filter_map(|(i, x)| match x { + LCurve::Circle(p, r) if p == center => Some((i, *r)), + _ => None, + }) + .collect(); + let mut incoming: Vec<(usize, f64)> = candidates + .iter() + .filter_map(|i| match lay.curves.get(*i) { + Some(LCurve::Circle(_, r)) => Some((*i, *r)), + _ => None, + }) + .collect(); + previous.sort_by(|a, b| a.1.total_cmp(&b.1)); + incoming.sort_by(|a, b| a.1.total_cmp(&b.1)); + let distinct = |xs: &[(usize, f64)]| { + xs.windows(2).all(|w| matches!(w, [a, b] if b.1 - a.1 > MERGE_TOL * (1.0 + a.1.abs().max(b.1.abs())))) + }; + if previous.len() == incoming.len() && distinct(&previous) && distinct(&incoming) { + previous.iter().position(|(i, _)| *i == curve_i).and_then(|rank| incoming.get(rank).map(|(i, _)| *i)) + } else { + None + } + } else { + None + }; + let found = found.filter(|i| !used.contains(i)); if let Some(i) = found { used.push(i); curve_map.push(i); @@ -649,10 +718,47 @@ impl Sketch { { *r = *value; } + if let (Some(Curve { kind: CurveKind::Conic { rho, .. }, .. }), Some(LCurve::Conic(.., value))) = + (self.curves.get_mut(*ci), lay.curves.get(li)) + { + *rho = *value; + } + if let (Some(Curve { kind: CurveKind::Ellipse { r, .. }, .. }), Some(LCurve::Ellipse(.., value))) = + (self.curves.get_mut(*ci), lay.curves.get(li)) + { + *r = *value; + } } return Ok(false); } } + // Equal topology alone does not identify a face boundary: an outer rim and a + // hole can have identical point graphs. Never move saved curve ids between them + // when the geometric match is ambiguous; the document reports a lost link. + if same && self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) { + self.set_link_lost(id, true); + return Err(SketchError::Invalid("projected face boundary identity is ambiguous; last geometry retained".into())); + } + if same { + for (k, pi) in points.iter().enumerate() { + let p = order.get(k).and_then(|li| lay.pts.get(*li)).copied(); + if let (Some(p), Some(sp)) = (p, self.points.get_mut(*pi)) { + sp.pos = p; + } + } + for (ci, c) in curves.iter().zip(&new) { + if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) { + *slot = *r; + } + if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) { + *rho = *r; + } + if let (LCurve::Ellipse(.., r), Some(Curve { kind: CurveKind::Ellipse { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) { + *slot = *r; + } + } + return Ok(false); + } // Rebuild: drop the old entities (constraints on them go too) and make new ones. self.drop_link_entities(id); self.build_link(id, &lay)?; @@ -724,3 +830,7 @@ impl Sketch { self.links.retain(|_| k.next().copied().unwrap_or(true)); } } + +#[cfg(test)] +#[path = "link_identity_tests.rs"] +mod identity_tests; diff --git a/crates/sketch/src/link_identity_tests.rs b/crates/sketch/src/link_identity_tests.rs new file mode 100644 index 00000000..d9c202b1 --- /dev/null +++ b/crates/sketch/src/link_identity_tests.rs @@ -0,0 +1,158 @@ +use super::*; +use crate::{ConstraintKind, solve}; + +fn square(size: f64) -> Vec { + let pts = [Vec2::new(-size, -size), Vec2::new(size, -size), Vec2::new(size, size), Vec2::new(-size, size)]; + (0..4).map(|i| LinkGeom::Line(pts[i], pts[(i + 1) % 4])).collect() +} + +fn face() -> LinkSource { + LinkSource::Face { body: "Case".into(), at: Vec3::ZERO } +} + +fn ring(outer_first: bool, outer: f64, inner: f64) -> Vec { + let (first, second) = if outer_first { (outer, inner) } else { (inner, outer) }; + square(first).into_iter().chain(square(second)).collect() +} + +#[test] +fn saved_face_link_keeps_inner_ids_when_boundary_order_changes() { + let mut sk = Sketch::new(); + // An older saved face may have enumerated its inner boundary first. + let link = sk.add_link(LinkKind::Project, face(), &ring(false, 10.0, 8.0)).unwrap(); + let inner = sk.link_curves(&link)[0]; + let corner = match sk.curves[inner].kind { + CurveKind::Line { a, .. } => a, + _ => panic!(), + }; + let free = sk.add_point(Vec2::new(-8.0, -8.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Coincident { p: free, q: corner }, None).unwrap(); + let serialized = serde_json::to_string(&sk).unwrap(); + let mut sk: Sketch = serde_json::from_str(&serialized).unwrap(); + let before = sk.clone(); + assert!(!sk.update_link(&link, &ring(true, 10.0, 8.0)).unwrap()); + assert_eq!(sk, before, "canonical outer-first enumeration must not reinterpret saved ids"); + assert!(solve(&mut sk).ok()); + assert_eq!(sk.points[free].pos, Vec2::new(-8.0, -8.0)); +} + +#[test] +fn resized_and_reordered_face_keeps_constraints_on_the_same_boundary() { + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, face(), &ring(false, 10.0, 8.0)).unwrap(); + let points = sk.link_points(&link); + let original: Vec = points.iter().map(|p| sk.points[*p].pos).collect(); + let ids: Vec = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect(); + let free = sk.add_point(Vec2::new(-8.0, -8.0), None).unwrap(); + sk.add_constraint(ConstraintKind::Coincident { p: free, q: points[0] }, None).unwrap(); + let fresh: Vec = ring(true, 10.0, 8.0) + .into_iter() + .map(|g| match g { + LinkGeom::Line(a, b) => LinkGeom::Line(Vec2::new(b.x * 2.0, b.y * 3.0), Vec2::new(a.x * 2.0, a.y * 3.0)), + _ => panic!(), + }) + .collect(); + assert!(!sk.update_link(&link, &fresh).unwrap()); + for (p, old) in points.iter().zip(original) { + assert_eq!(sk.points[*p].pos, Vec2::new(old.x * 2.0, old.y * 3.0)); + } + assert_eq!(ids, sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect::>()); + assert!(solve(&mut sk).ok()); + assert!(sk.points[free].pos.dist(Vec2::new(-16.0, -24.0)) < 1e-7); +} + +#[test] +fn ambiguous_face_refresh_preserves_last_geometry_and_marks_link_lost() { + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, face(), &ring(true, 10.0, 9.9)).unwrap(); + let before = sk.clone(); + assert!(sk.update_link(&link, &ring(false, 10.0, 2.0)).is_err()); + assert_eq!(sk.points, before.points); + assert_eq!(sk.curves, before.curves); + assert!(sk.link(&link).unwrap().lost); +} + +#[test] +fn large_face_permutation_is_bounded_and_preserves_geometry() { + let mut sk = Sketch::new(); + let geom: Vec = (1..=129).flat_map(|i| square(i as f64)).collect(); + let link = sk.add_link(LinkKind::Project, face(), &geom).unwrap(); + assert!(!sk.update_link(&link, &geom).unwrap(), "ordered unchanged geometry needs no permutation scan"); + let before = sk.clone(); + let reversed: Vec = (1..=129).rev().flat_map(|i| square(i as f64)).collect(); + assert!(sk.update_link(&link, &reversed).is_err()); + assert_eq!(sk.points, before.points); + assert_eq!(sk.curves, before.curves); +} + +#[test] +fn geometric_refresh_updates_conic_and_ellipse_scalar_values() { + let mut sk = Sketch::new(); + let geom = |rho, minor| { + vec![ + LinkGeom::Conic { a: Vec2::new(1.0, 1.0), apex: Vec2::new(2.0, 3.0), b: Vec2::new(4.0, 1.0), rho }, + LinkGeom::Ellipse { c: Vec2::new(10.0, 10.0), major: Vec2::new(14.0, 10.0), minor }, + ] + }; + let link = sk.add_link(LinkKind::Project, LinkSource::Origin, &geom(0.4, 2.0)).unwrap(); + assert!(!sk.update_link(&link, &geom(0.6, 3.0)).unwrap()); + assert!(matches!(sk.curves[0].kind, CurveKind::Conic { rho, .. } if rho == 0.6)); + assert!(matches!(sk.curves[1].kind, CurveKind::Ellipse { r, .. } if r == 3.0)); +} + +#[test] +fn many_curves_sharing_few_points_do_not_enter_permutation_matching() { + let mut sk = Sketch::new(); + let geom = vec![LinkGeom::Line(Vec2::new(1.0, 1.0), Vec2::new(2.0, 2.0)); 513]; + let link = sk.add_link(LinkKind::Project, face(), &geom).unwrap(); + assert_eq!(sk.link_points(&link).len(), 2); + let before = sk.clone(); + let shifted = vec![LinkGeom::Line(Vec2::new(3.0, 3.0), Vec2::new(4.0, 4.0)); 513]; + assert!(sk.update_link(&link, &shifted).is_err()); + assert_eq!(sk.points, before.points); + assert_eq!(sk.curves, before.curves); + assert!(sk.link(&link).unwrap().lost); +} + +#[test] +fn concentric_face_resize_keeps_inner_outer_circle_identity() { + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 8.0), LinkGeom::Circle(Vec2::ZERO, 10.0)]).unwrap(); + let ids: Vec = sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect(); + assert!(!sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 20.0), LinkGeom::Circle(Vec2::ZERO, 16.0)]).unwrap()); + assert!(matches!(sk.curves[0].kind, CurveKind::Circle { r, .. } if r == 16.0)); + assert!(matches!(sk.curves[1].kind, CurveKind::Circle { r, .. } if r == 20.0)); + assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::>()); + // Coincident circles do not have a unique boundary identity. + let before = sk.clone(); + assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 18.0), LinkGeom::Circle(Vec2::ZERO, 18.0)]).is_err()); + assert_eq!(sk.points, before.points); + assert_eq!(sk.curves, before.curves); +} + +#[test] +fn resized_dense_rim_matches_arc_centres_by_role_before_proximity() { + let mut sk = Sketch::new(); + let geometry = |half_width: f64, x: f64| { + let mut out = square(half_width); + let c = Vec2::new(x, -16.5); + let left = c - Vec2::X * 3.0; + let right = c + Vec2::X * 3.0; + out.push(LinkGeom::Arc { c, a: right, b: left }); + out.push(LinkGeom::Arc { c, a: left, b: right }); + out + }; + let link = sk.add_link(LinkKind::Project, face(), &geometry(38.0, -26.5)).unwrap(); + let arcs: Vec = sk.link_curves(&link).into_iter().filter(|i| matches!(sk.curves[*i].kind, CurveKind::Arc { .. })).collect(); + let ids: Vec = arcs.iter().map(|i| sk.curves[*i].id.clone()).collect(); + // The old centre is spatially closer in normalized space to a fresh endpoint. + // Both semicircles must still keep the same shared centre and logical endpoints. + assert!(!sk.update_link(&link, &geometry(43.0, -31.5)).unwrap()); + for i in &arcs { + let CurveKind::Arc { c, a, b } = sk.curves[*i].kind else { panic!() }; + assert_eq!(sk.points[c].pos, Vec2::new(-31.5, -16.5)); + assert!((sk.points[a].pos.dist(sk.points[c].pos) - 3.0).abs() < 1e-9); + assert!((sk.points[b].pos.dist(sk.points[c].pos) - 3.0).abs() < 1e-9); + } + assert_eq!(ids, arcs.iter().map(|i| sk.curves[*i].id.clone()).collect::>()); +} diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index c703e2e6..b1779f56 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -9,7 +9,7 @@ //! gives the remaining degrees of freedom and which points are fully determined. use serde::{Deserialize, Serialize}; -use solvecraft_geom::Vec2; +use solvecraft_geom::{Seg2, Vec2}; use crate::linalg::{Mat, determined_vars, solve as lin_solve}; use crate::model::{ConstraintKind, CurveKind, Sketch}; @@ -177,6 +177,51 @@ impl State<'_> { _ => Vec::new(), } } + /// Point-on-curve constraints use the exact shape. A polyline residual would move an + /// exact crossing onto its chord, changing geometry merely by splitting it. + fn freeform_dist(&self, c: usize, q: Vec2) -> f64 { + let segs: Vec = match self.sk.curves.get(c).map(|c| &c.kind) { + Some(CurveKind::Ellipse { c: ci, m, .. }) => { + let center = self.p(*ci); + let u = self.p(*m) - center; + let a = u.len(); + let r = self.radius_of(c); + if a <= 1e-12 || r <= 1e-12 { + return q.dist(center); + } + let delta = q - center; + let x = delta.dot(u) / (a * a); + let y = delta.cross(u) / (a * r); + return (x.hypot(y) - 1.0) * a.min(r); + } + Some(CurveKind::Spline { pts, control, degree }) => { + let p: Vec = pts.iter().map(|i| self.p(*i)).collect(); + if p.len() < 2 { + return q.dist(p.first().copied().unwrap_or_default()); + } + if *control { + let k = (*degree as usize).min(solvecraft_geom::MAX_BEZIER_DEGREE).min(p.len().saturating_sub(1)).max(1); + crate::curves::bspline_beziers(&p, k).iter().filter_map(|span| solvecraft_geom::bezier(span)).collect() + } else { + crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect() + } + } + Some(CurveKind::Conic { a, b, apex, rho }) => { + vec![Seg2::Conic { a: self.p(*a), b: self.p(*b), apex: self.p(*apex), w: rho / (1.0 - rho).max(1e-9) }] + } + _ => Vec::new(), + }; + segs.iter() + .map(|seg| { + let t = seg.closest_param(q); + let delta = q - seg.point_at(t); + let d = delta.len(); + (d, if seg.derivative(t).cross(delta) >= 0.0 { d } else { -d }) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, signed)| signed) + .unwrap_or_else(|| crate::curves::polyline_signed_dist(&self.poly(c), q)) + } /// Radius of a circle or arc; an ellipse's minor radius. fn radius_of(&self, c: usize) -> f64 { match self.sk.curves.get(c).map(|x| &x.kind) { @@ -281,7 +326,7 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { PointOnCurve { p, c } => { let pp = s.p(p); if s.is_freeform(c) { - out.push(crate::curves::polyline_signed_dist(&s.poly(c), pp)); + out.push(s.freeform_dist(c, pp)); } else if s.is_line(c) { let (a, b) = s.line(c); out.push(line_dist(pp, a, b)); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 3b9ba9ab..cc936c9f 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -366,7 +366,7 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(act("ui.editFeature", "Edit Feature", "").with(json!({ "feature": feature })).on(feature.is_some())); v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some())); v.push(Item::sep()); - v.push(act("ui.appearance", "Appearance", "").key("A").on(false)); + v.push(act("ui.appearance", "Appearance", "appearance").key("A")); v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] }))); v.push(Item::sep()); v.push(act("ui.delete", "Delete", "delete").key("Del")); @@ -437,7 +437,7 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { cmd(app, "component.from_bodies", "Create Components from Bodies").with(json!({ "bodies": names })), Item::sep(), act("ui.material", "Physical Material", "").with(json!({ "bodies": names })), - act("ui.appearance", "Appearance", "").key("A").on(false), + act("ui.appearance", "Appearance", "appearance").key("A").with(json!({ "bodies": names })), act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })), Item::sep(), act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })), @@ -530,8 +530,13 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { } v.push(Item::sep()); v.push(act("ui.material", "Physical Material", "").with(json!({ "bodies": names })).on(!bodies.is_empty())); - v.push(act("ui.appearance", "Appearance", "").key("A").on(false)); - v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })).on(!bodies.is_empty())); + v.push( + act("ui.appearance", "Appearance", "appearance") + .key("A") + .with(if id == 0 { json!({ "bodies": names }) } else { json!({"component": id}) }) + .on(id != 0 || !bodies.is_empty()), + ); + v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id }))); v.push(Item::sep()); v.push(act("ui.export", "Export…", "export").with(json!({ "bodies": names })).on(!bodies.is_empty())); v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })).on(!bodies.is_empty())); @@ -716,10 +721,20 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { }; start_rename(app, what, at); } - "ui.properties" => match app.session.execute("inspect.measure", &json!({ "bodies": bodies })) { - Ok(v) => app.menu.props = Some((if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) }, v)), - Err(e) => app.set_status(e.to_string(), true), - }, + "ui.properties" => { + let command = if p.get("component").is_some() { "component.properties" } else { "inspect.measure" }; + let params = if command == "component.properties" { p.clone() } else { json!({ "bodies": bodies }) }; + match app.session.execute(command, ¶ms) { + Ok(v) => { + let title = v["name"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) }); + app.menu.props = Some((title, v)); + } + Err(e) => app.set_status(e.to_string(), true), + } + } "ui.export" => { let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone()); if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"])) @@ -847,6 +862,26 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { app.edit_feature(f); } } + "ui.appearance" => { + if item.params.is_some() { + let items: Vec = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect(); + let _ = app.run("select.set", json!({ "items": items })); + } + app.start("appearance.assign"); + if let Some(name) = id_of(&p, "component").and_then(|id| app.session.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone())) + && let Some(d) = app.dialog.as_mut() + && let crate::dialogs::Kind::Appearance(k) = &mut d.kind + { + if let Some(id) = id_of(&p, "component") + && let Some(look) = app.session.doc.appearances.components.get(&id) + { + k.color = look.color; + k.opacity = look.opacity; + k.look = Some(look.name.clone()); + } + k.component = Some(name); + } + } "ui.material" => { let items: Vec = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect(); let _ = app.run("select.set", json!({ "items": items })); @@ -1502,7 +1537,8 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) { ui.label("Area"); ui.label(crate::prefs::show_mm(app, f(total, "area_mm2"), 2)); ui.end_row(); - if let [b] = bodies.as_slice() { + let geometry = if total.get("center_of_mass").is_some() { Some(total) } else { bodies.first().filter(|_| bodies.len() == 1) }; + if let Some(b) = geometry { if let Some(c) = b["center_of_mass"].as_array() { let c: Vec = c.iter().map(|x| crate::prefs::show_mm_bare(app, x.as_f64().unwrap_or(f64::NAN))).collect(); ui.label("Center of mass"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5038b79e..520b081d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1226,6 +1226,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .stroke(Stroke::new(1.0, t.dialog_border)) .corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 }) .shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) }); + // The area includes the border as well as the inner margins; reserve all of it so a + // horizontally scrolling table cannot push a wide dialog two pixels past its limit. + let horizontal_frame = frame.inner_margin.leftf() + frame.inner_margin.rightf() + 2.0 * frame.stroke.width; // OK is offered once the inputs make a feature. let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. }); let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none()); @@ -1245,7 +1248,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.spacing_mut().text_edit_width = FIELD_W; // Long texts (errors, notes) wrap instead of widening the dialog. ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); - let margin = 2.0 * ui.style().spacing.window_margin.leftf(); + let margin = horizontal_frame; let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin; ui.set_min_width(min_w); ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin); @@ -1268,7 +1271,15 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { return; } ui.add_space(2.0); + // Configuration tables own their column widths and scrolling; the two-column form + // grid would squeeze their row names and action buttons into a label-sized cell. + if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind { + enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs); + } egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) { + return; + } placement_row(&mut d, ui); input_rows(&mut d, ui); match &mut d.kind { diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index 6890658e..b55b42e1 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -19,12 +19,12 @@ pub struct Look { pub name: String, pub category: String, pub color: [u8; 3], - pub opacity: f32, + pub opacity: f64, } /// Built-in looks, used until the engine offers a library (`appearance.library`). fn builtin() -> Vec { - let l = |cat: &str, name: &str, c: [u8; 3], o: f32| Look { name: name.into(), category: cat.into(), color: c, opacity: o }; + let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o }; vec![ l("Metal", "Aluminum - Satin", [196, 199, 204], 1.0), l("Metal", "Steel - Brushed", [160, 164, 170], 1.0), @@ -63,7 +63,7 @@ pub fn library(app: &mut SolveApp) -> Vec { name: x.get("name")?.as_str()?.to_string(), category: x.get("category").and_then(Value::as_str).unwrap_or("Library").to_string(), color: parse_color(x.get("color")?)?, - opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32, + opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), }) }) .collect(); @@ -96,27 +96,61 @@ pub struct Ap { /// Apply to faces instead of bodies. pub faces: bool, pub color: [u8; 3], - pub opacity: f32, + pub opacity: f64, /// The look picked last (its name), if the colour came from one. pub look: Option, /// Library category shown (0: all). pub category: usize, + /// Library names can be searched without changing the selected look. + pub search: String, + /// A Browser component target, inherited by its bodies unless they have overrides. + pub component: Option, + pub remove_override: bool, + /// The last successful immediate Apply/drop, so OK does not duplicate its undo step. + pub applied: Option, /// What the last drop or Apply did. pub note: Option, } /// The dialog for the Appearance command. -pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { +pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { (id == "appearance.assign").then(|| { + let faces = app.session.selection.iter().any(|s| matches!(s, Sel::Face { .. })) + && !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. })); + let state = app.session.world_state(); + let existing = app.session.selection.iter().find_map(|sel| match sel { + Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.body_look(name, b.feature)), + Sel::Face { body, index, .. } => state.body(body).and_then(|b| { + app.session + .doc + .face_colors(b) + .into_iter() + .find(|(i, _)| i == index) + .map(|(_, look)| look) + .or_else(|| app.session.doc.body_look(body, b.feature)) + }), + _ => None, + }); ( - Kind::Appearance(Ap { faces: false, color: [196, 199, 204], opacity: 1.0, look: None, category: 0, note: None }), - vec![SelInput::new("Objects", BODIES, true)], + Kind::Appearance(Ap { + faces, + color: existing.as_ref().map_or([196, 199, 204], |l| l.color), + opacity: existing.as_ref().map_or(1.0, |l| l.opacity), + look: existing.map(|l| l.name), + category: 0, + search: String::new(), + component: None, + remove_override: false, + applied: None, + note: None, + }), + vec![SelInput::new("Objects", if faces { FACES } else { BODIES }, true)], ) }) } /// The command giving `targets` (bodies or faces) a look. -fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str>) -> Option { +fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str>) -> Option { let bodies: Vec = targets.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect(); let faces: Vec = targets .iter() @@ -139,9 +173,7 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str p["bodies"] = json!(owners); } } - if opacity < 0.999 { - p["opacity"] = json!((opacity * 1000.0).round() / 1000.0); - } + p["opacity"] = json!(opacity); if let Some(n) = look { p["appearance"] = json!(n); } @@ -150,8 +182,35 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str /// The commands OK runs: the current look on the picked objects (nothing when none are picked). pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> { + // Remove override already ran immediately; OK closes without creating a second undo step. + if k.remove_override { + return Vec::new(); + } let items = inputs.first().map(|i| i.items.as_slice()).unwrap_or(&[]); - apply_params(items, k.color, k.opacity, k.look.as_deref()).map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default() + let p = selected_params(k, items); + if p.as_ref().is_some_and(|p| k.applied.as_ref() == Some(p)) { + return Vec::new(); + } + p.map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default() +} + +fn selected_params(k: &Ap, items: &[Sel]) -> Option { + if let Some(component) = &k.component { + Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look})) + } else { + apply_params(items, k.color, k.opacity, k.look.as_deref()) + } +} + +/// Removing an override restores the inherited component or material appearance. +fn clear_params(k: &Ap, items: &[Sel]) -> Option { + let mut p = selected_params(k, items)?; + if let Some(obj) = p.as_object_mut() { + obj.remove("appearance"); + obj.remove("color"); + obj.remove("opacity"); + } + Some(p) } /// What a swatch dropped now would land on: the face or body under the cursor in the viewport, @@ -166,32 +225,43 @@ fn drop_target(app: &SolveApp, faces: bool) -> Vec { /// The looks in this design (colour and how many items wear it): the engine's list when it has /// one, else the body colours. -fn in_design(app: &mut SolveApp) -> Vec<([u8; 3], usize)> { +fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> { if let Ok(v) = app.session.execute("appearance.list", &json!({})) && let Some(list) = v.get("in_design").and_then(Value::as_array) { let count = |x: &Value, k: &str| x.get(k).and_then(Value::as_array).map_or(0, Vec::len); return list .iter() - .filter_map(|x| Some((parse_color(x.get("color")?)?, count(x, "bodies") + count(x, "faces") + count(x, "components")))) + .filter_map(|x| { + Some(( + Look { + name: x.get("name").and_then(Value::as_str).unwrap_or("Custom").to_string(), + category: "In This Design".into(), + color: parse_color(x.get("color")?)?, + opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), + }, + count(x, "bodies") + count(x, "faces") + count(x, "components"), + )) + }) .collect(); } - let mut out: Vec<([u8; 3], usize)> = Vec::new(); - for c in app.session.doc.appearances.bodies.values().map(|l| l.color) { - match out.iter_mut().find(|(x, _)| *x == c) { + let mut out: Vec<(Look, usize)> = Vec::new(); + for l in app.session.doc.appearances.bodies.values() { + let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity }; + match out.iter_mut().find(|(x, _)| *x == look) { Some(e) => e.1 += 1, - None => out.push((c, 1)), + None => out.push((look, 1)), } } out } -fn c32(c: [u8; 3], opacity: f32) -> Color32 { +fn c32(c: [u8; 3], opacity: f64) -> Color32 { Color32::from_rgba_unmultiplied(c[0], c[1], c[2], (opacity.clamp(0.0, 1.0) * 255.0) as u8) } /// A swatch: a rounded square of the look; returns its response (click, drag). -fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked: bool, tip: &str, payload: Look) -> egui::Response { +fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f64, picked: bool, tip: &str, payload: Look) -> egui::Response { let t = Tokens::get(); let r = ui .dnd_drag_source(id, payload, |ui| { @@ -200,7 +270,7 @@ fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked: if opacity < 0.999 { let h = rect.width() / 2.0; for (dx, dy) in [(0.0, 0.0), (h, h)] { - ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, Color32::from_gray(200)); + ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, t.hover); } } ui.painter().rect( @@ -238,13 +308,23 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel let t = Tokens::get(); // Apply To: bodies or faces (the input follows). row_label(ui, "Apply To"); - let mut mode = usize::from(k.faces); - combo(ui, "ap_mode", &["Bodies/Components", "Faces"], &mut mode); - ui.end_row(); - if (mode == 1) != k.faces { - k.faces = mode == 1; - if let Some(i) = inputs.first_mut() { - *i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true); + if let Some(component) = &k.component { + ui.label(format!("Component: {component}")); + ui.end_row(); + row_label(ui, ""); + if ui.button("Select bodies or faces instead").clicked() { + k.component = None; + } + ui.end_row(); + } else { + let mut mode = usize::from(k.faces); + combo(ui, "ap_mode", &["Bodies", "Faces"], &mut mode); + ui.end_row(); + if (mode == 1) != k.faces { + k.faces = mode == 1; + if let Some(i) = inputs.first_mut() { + *i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true); + } } } // The current look: colour and opacity. @@ -254,35 +334,71 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel if ui.color_edit_button_srgb(&mut rgb).changed() { k.color = rgb; k.look = None; + k.remove_override = false; } ui.label(RichText::new(k.look.clone().unwrap_or_else(|| hex(k.color))).color(t.text_dim)); }); ui.end_row(); row_label(ui, "Opacity"); ui.spacing_mut().slider_width = 110.0; - ui.add(egui::Slider::new(&mut k.opacity, 0.05..=1.0).fixed_decimals(2)); + if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).fixed_decimals(2)).changed() { + k.remove_override = false; + } ui.end_row(); row_label(ui, ""); let picked: Vec = inputs.first().map(|i| i.items.clone()).unwrap_or_default(); - if ui.add_enabled(!picked.is_empty(), egui::Button::new("Apply to selection")).clicked() - && let Some(p) = apply_params(&picked, k.color, k.opacity, k.look.as_deref()) - { - k.note = Some(match app.run("appearance.assign", p) { - Ok(_) => format!("applied to {} item(s)", picked.len()), - Err(e) => e, - }); - } + ui.horizontal(|ui| { + let has_target = !picked.is_empty() || k.component.is_some(); + if ui.add_enabled(has_target, egui::Button::new("Apply to selection")).clicked() + && let Some(p) = selected_params(k, &picked) + { + k.remove_override = false; + k.note = Some(match app.run("appearance.assign", p.clone()) { + Ok(_) => { + k.applied = Some(p); + "Appearance applied".into() + } + Err(e) => e, + }); + } + if ui + .add_enabled(has_target, egui::Button::new("Remove override")) + .on_hover_text("Restore the inherited component or physical material appearance") + .clicked() + && let Some(p) = clear_params(k, &picked) + { + k.note = Some(match app.run("appearance.assign", p) { + Ok(_) => { + k.remove_override = true; + k.applied = None; + "Inherited appearance restored".into() + } + Err(e) => e, + }); + } + }); ui.end_row(); // In this design. let used = in_design(app); if !used.is_empty() { row_label(ui, "In This Design"); - let items: Vec<(usize, &([u8; 3], usize))> = used.iter().enumerate().collect(); - swatch_rows(ui, &items, |ui, (i, (c, n))| { - let look = Look { name: hex(*c), category: "In This Design".into(), color: *c, opacity: 1.0 }; - if swatch(ui, egui::Id::new(("ap_used", *i)), *c, 1.0, k.color == *c, &format!("{} · {n} item(s)", hex(*c)), look).clicked() { - k.color = *c; - k.look = None; + let items: Vec<_> = used.iter().enumerate().collect(); + swatch_rows(ui, &items, |ui, (i, (look, n))| { + if swatch( + ui, + egui::Id::new(("ap_used", *i)), + look.color, + look.opacity, + k.color == look.color && k.opacity == look.opacity, + &format!("{} · {n} item(s)", look.name), + look.clone(), + ) + .clicked() + { + k.color = look.color; + k.opacity = look.opacity; + k.look = Some(look.name.clone()); + k.remove_override = false; } }); ui.end_row(); @@ -299,9 +415,18 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel row_label(ui, "Library"); combo(ui, "ap_cat", &refs, &mut k.category); ui.end_row(); + row_label(ui, "Search"); + let search = ui.add(egui::TextEdit::singleline(&mut k.search).hint_text("Appearance name").desired_width(190.0)); + crate::scenario::publish_handle("appearance:search", search.rect.center()); + ui.end_row(); row_label(ui, ""); let want = cats.get(k.category).cloned().unwrap_or_default(); - let shown: Vec<(usize, &Look)> = lib.iter().enumerate().filter(|(_, l)| k.category == 0 || l.category == want).collect(); + let query = k.search.trim().to_lowercase(); + let shown: Vec<(usize, &Look)> = + lib.iter().enumerate().filter(|(_, l)| (k.category == 0 || l.category == want) && l.name.to_lowercase().contains(&query)).collect(); + if shown.is_empty() { + ui.label(RichText::new("No matching appearances").color(t.text_dim)); + } swatch_rows(ui, &shown, |ui, (i, l)| { let picked_look = k.look.as_deref() == Some(l.name.as_str()); if swatch(ui, egui::Id::new(("ap_lib", *i)), l.color, l.opacity, picked_look, &format!("{} (drag onto a body or face)", l.name), (*l).clone()) @@ -310,6 +435,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel k.color = l.color; k.opacity = l.opacity; k.look = Some(l.name.clone()); + k.remove_override = false; } }); ui.end_row(); @@ -319,10 +445,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel let targets = drop_target(app, k.faces); k.color = look.color; k.opacity = look.opacity; - k.look = (look.category != "In This Design").then(|| look.name.clone()); + k.look = Some(look.name.clone()); + k.remove_override = false; if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) { - k.note = Some(match app.run("appearance.assign", p) { - Ok(_) => format!("{} on {}", look.name, target_names(&targets)), + k.note = Some(match app.run("appearance.assign", p.clone()) { + Ok(_) => { + k.applied = Some(p); + format!("{} on {}", look.name, target_names(&targets)) + } Err(e) => e, }); } @@ -392,7 +522,11 @@ mod tests { a.run(&id, p).unwrap(); } assert_eq!(a.session.doc.appearances.bodies.get("Body2").map(|l| l.color), Some([196, 40, 36])); - assert_eq!(in_design(&mut a), vec![([196, 40, 36], 2)]); + let used = in_design(&mut a); + assert_eq!(used.len(), 1); + assert_eq!(used[0].0.color, [196, 40, 36]); + assert_eq!(used[0].0.name, "Paint - Red"); + assert_eq!(used[0].1, 2); } /// A swatch dropped on a face in face mode targets the face; otherwise its body. @@ -411,4 +545,73 @@ mod tests { assert_eq!(face["opacity"], 0.5); assert!(!library(&mut a).is_empty()); } + + #[test] + fn face_preselection_keeps_the_face_and_remove_survives_ok() { + let mut a = app(); + let point = Vec3::new(5.0, 5.0, 10.0); + let selected = vec![Sel::Face { body: "Body1".into(), index: 0, point }]; + a.session.selection = selected.clone(); + a.start("appearance.assign"); + let mut d = a.dialog.clone().unwrap(); + assert_eq!(d.inputs[0].items, selected); + let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") }; + assert!(k.faces); + k.color = [10, 20, 30]; + k.opacity = 0.25; + for (id, p) in commands(k, &d.inputs) { + a.run(&id, p).unwrap(); + } + assert_eq!(a.session.doc.appearances.faces.len(), 1); + assert!(a.session.doc.appearances.bodies.is_empty()); + a.run("appearance.assign", clear_params(k, &d.inputs[0].items).unwrap()).unwrap(); + k.remove_override = true; + assert!(commands(k, &d.inputs).is_empty()); + assert!(a.session.doc.appearances.faces.is_empty()); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(a.session.doc.appearances.faces.len(), 1); + } + + #[test] + fn reused_design_look_preserves_transparency_and_name() { + let mut a = app(); + a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "appearance": "Tinted glass", "opacity": 0.25})).unwrap(); + let used = in_design(&mut a); + let look = &used[0].0; + assert_eq!(look.opacity, 0.25); + assert_eq!(look.name, "Tinted glass"); + a.session.selection = vec![Sel::Body { name: "Body1".into() }]; + a.start("appearance.assign"); + let Kind::Appearance(k) = &a.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") }; + assert_eq!(k.opacity, 0.25); + assert_eq!(k.look.as_deref(), Some("Tinted glass")); + let p = apply_params(&[Sel::Body { name: "Body2".into() }], look.color, look.opacity, Some(&look.name)).unwrap(); + a.run("appearance.assign", p).unwrap(); + assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2")); + } + + #[test] + fn immediate_apply_then_ok_has_one_undo_and_retains_precise_opacity() { + let mut a = app(); + a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "opacity": 0.123456})).unwrap(); + a.session.selection = vec![Sel::Body { name: "Body1".into() }]; + a.start("appearance.assign"); + let mut d = a.dialog.clone().unwrap(); + let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") }; + assert_eq!(commands(k, &d.inputs)[0].1["opacity"], json!(0.123456)); + k.color = [1, 2, 3]; + let p = selected_params(k, &d.inputs[0].items).unwrap(); + a.run("appearance.assign", p.clone()).unwrap(); + k.applied = Some(p); + assert!(commands(k, &d.inputs).is_empty()); + a.run("edit.undo", json!({})).unwrap(); + let restored = a.session.doc.appearances.bodies.get("Body1").unwrap(); + assert_eq!(restored.color, [171, 205, 239]); + assert_eq!(restored.opacity, 0.123456); + k.opacity = 0.5; + assert_eq!(commands(k, &d.inputs).len(), 1, "changed look still applies on OK"); + k.opacity = 0.123456; + d.inputs[0].items = vec![Sel::Body { name: "Body2".into() }]; + assert_eq!(commands(k, &d.inputs).len(), 1, "changed target still applies on OK"); + } } diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 5331e429..d0a3bb5d 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -597,56 +597,115 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel let run = |app: &mut SolveApp, error: &mut Option, id: &str, p: Value| { *error = app.run(id, p).err(); }; - // Header. - ui.label(RichText::new("Configuration").strong()); - ui.horizontal(|ui| { - for c in &tb.columns { - let (kind, n) = column_parts(c); - ui.add_sized(vec2(96.0, 18.0), egui::Label::new(RichText::new(n).strong()).truncate()) - .on_hover_text(format!("{} {n}", if kind == "suppress" { "Suppress" } else { "Parameter" })); - if ui.small_button("×").on_hover_text("Remove the column").clicked() { - let key = if kind == "suppress" { "feature" } else { "param" }; - run(app, error, "config.column", json!({ key: n, "delete": true })); - } - } - }); - ui.end_row(); - for (name, vals) in &tb.rows { - let active = tb.active.as_deref() == Some(name.as_str()); - ui.horizontal(|ui| { - ui.label(if active { RichText::new(name).strong() } else { RichText::new(name) }); - if active { - ui.label(RichText::new("Active").color(t.accent)); - } else if ui.button("Activate").clicked() { - run(app, error, "config.activate", json!({ "row": name })); - } - }); - ui.horizontal(|ui| { - for (c, val) in tb.columns.iter().zip(vals) { - let id = egui::Id::new(("cfg", name, c)); - let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| val.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(96.0)); - if r.changed() { - ui.data_mut(|d| d.insert_temp(id, text.clone())); + egui::ScrollArea::horizontal().id_salt("configuration_columns").auto_shrink([false, true]).show(ui, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + egui::Grid::new("configuration_table").num_columns(2).min_col_width(210.0).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + // Header. + ui.label(RichText::new("Configuration").strong()); + ui.horizontal(|ui| { + for c in &tb.columns { + ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + let (kind, n) = column_parts(c); + let label = match kind { + "material" => format!("Material: {n}"), + "appearance" => format!("Appearance: {n}"), + "suppress" => format!("Suppress: {n}"), + _ => n.to_string(), + }; + ui.add_sized(vec2(156.0, 22.0), egui::Label::new(RichText::new(label).strong()).truncate()).on_hover_text(c); + if ui.small_button("×").on_hover_text("Remove the column").clicked() { + let key = if kind == "suppress" { "feature" } else { kind }; + run(app, error, "config.column", json!({ key: n, "delete": true })); + } + }); } - if r.lost_focus() { - ui.data_mut(|d| d.remove::(id)); - if text != *val { - let (_, n) = column_parts(c); - run(app, error, "config.row", json!({"name": name, "values": { n: cell_value(c, &text) }})); + }); + ui.end_row(); + for (name, vals) in &tb.rows { + let active = tb.active.as_deref() == Some(name.as_str()); + ui.horizontal(|ui| { + ui.add_sized( + vec2(100.0, 22.0), + egui::Label::new(if active { RichText::new(name).strong() } else { RichText::new(name) }).truncate(), + ) + .on_hover_text(name); + if active { + ui.label(RichText::new("Active").color(t.accent)); + if v.get("matches").and_then(Value::as_bool) == Some(false) && ui.button("Apply").clicked() { + run(app, error, "config.activate", json!({ "row": name })); + } + } else if ui.button("Activate").clicked() { + run(app, error, "config.activate", json!({ "row": name })); } - } - ui.add_space(18.0); - } - if ui.small_button("×").on_hover_text("Delete the row").clicked() { - run(app, error, "config.row", json!({"name": name, "delete": true})); + }); + ui.horizontal(|ui| { + for (c, val) in tb.columns.iter().zip(vals) { + ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + let (kind, _) = column_parts(c); + let shown = if kind == "appearance" { + solvecraft_engine::doc::config::appearance_cell(val) + .ok() + .flatten() + .filter(|look| solvecraft_engine::doc::appearance::library(&look.name).as_ref() == Some(look)) + .map(|look| look.name) + .unwrap_or_else(|| val.clone()) + } else { + val.clone() + }; + let style = matches!(kind, "material" | "appearance"); + if style { + let mut choice = None; + ui.menu_button("Library", |ui| { + if ui.button(if kind == "appearance" { "Follow material" } else { "Default material" }).clicked() { + choice = Some("Default".to_string()); + ui.close(); + } + if kind == "material" { + for (name, _) in solvecraft_engine::doc::MATERIALS.iter().filter(|(n, _)| *n != "Default") { + if ui.button(*name).clicked() { + choice = Some(name.to_string()); + ui.close(); + } + } + } else { + for (name, _, _) in solvecraft_engine::doc::appearance::LIBRARY { + if ui.button(name).clicked() { + choice = Some(name.to_string()); + ui.close(); + } + } + } + }); + if let Some(choice) = choice { + run(app, error, "config.row", json!({"name": name, "values": { c: choice }})); + } + } + let id = egui::Id::new(("cfg", name, c)); + let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(if style { 106.0 } else { 164.0 })); + if r.changed() { + ui.data_mut(|d| d.insert_temp(id, text.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(id)); + if text != shown { + run(app, error, "config.row", json!({"name": name, "values": { c: cell_value(c, &text) }})); + } + } + }); + } + if ui.small_button("×").on_hover_text("Delete the row").clicked() { + run(app, error, "config.row", json!({"name": name, "delete": true})); + } + }); + ui.end_row(); } }); - ui.end_row(); - } + }); // New row (a copy of the active one) and new column. - ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(130.0)); ui.horizontal(|ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(160.0)); if ui.button("Add Row").on_hover_text("A new configuration, a copy of the active one").clicked() { let name = if new_row.trim().is_empty() { (tb.rows.len() + 1..).map(|k| format!("Configuration{k}")).find(|n| !tb.rows.iter().any(|r| &r.0 == n)).unwrap_or_default() @@ -672,6 +731,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel options.push((format!("Suppress {}", f.name), json!({ "feature": f.name }))); } } + for body in &app.session.model.state().bodies { + for kind in ["material", "appearance"] { + if !tb.columns.contains(&format!("{kind}:{}", body.name)) { + options.push((format!("{} ({kind})", body.name), json!({kind: body.name}))); + } + } + } let mut pick: Option = None; ui.menu_button("Add Column", |ui| { egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { @@ -688,9 +754,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel let (id, p) = if tb.columns.is_empty() { ( "config.configure", - match p.get("param") { - Some(n) => json!({ "params": [n] }), - None => json!({ "features": [p.get("feature")] }), + if let Some(n) = p.get("param") { + json!({ "params": [n] }) + } else if let Some(n) = p.get("feature") { + json!({ "features": [n] }) + } else if let Some(n) = p.get("material") { + json!({ "materials": [n] }) + } else { + json!({ "appearances": [p.get("appearance")] }) }, ) } else { @@ -837,6 +908,86 @@ mod tests { assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm")); } + #[test] + fn configuration_style_picker_applies_then_inherits_material() { + let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap(); + let mut harness = crate::scenario::Harness::new(); + for step in steps.as_array().unwrap() { + harness.step(step).unwrap(); + match step.get("shot").and_then(Value::as_str) { + Some("configuration_red") => { + let look = harness.app.session.doc.appearances.bodies.get("Block").unwrap(); + assert_eq!(look.color, [196, 40, 40]); + assert!(harness.app.session.doc.configuration_matches()); + } + Some("configuration_default") => { + assert!(!harness.app.session.doc.appearances.bodies.contains_key("Block")); + let state = harness.app.session.model.state(); + let body = state.body("Block").unwrap(); + let look = harness.app.session.doc.body_look("Block", body.feature).unwrap(); + assert_eq!(look.name, "Steel"); + assert_eq!(look.color, [158, 164, 172]); + assert!(harness.app.session.doc.configuration_matches()); + } + _ => {} + } + } + } + + #[test] + fn configuration_table_actions_stay_on_one_line_at_normal_and_narrow_widths() { + for width in [1400, 900] { + let mut harness = crate::scenario::Harness::new(); + harness.call("ui.resize", json!({"width": width, "height": 720})); + harness + .run(&json!([ + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":20,"height":10,"body_name":"Block"}}}, + {"start":"config.table"} + ])) + .unwrap(); + assert_single_line(&harness, "Configuration"); + for name in ["span", "thickness", "depth", "clearance"] { + harness.call("engine.execute", json!({"command":"parameters.change","params":{"name":name,"expression":"10 mm"}})); + } + harness + .run(&json!([ + {"call":"engine.execute","params":{"command":"config.configure","params":{ + "params":["span","thickness","depth","clearance"],"materials":["Block"],"appearances":["Block"] + }}}, + {"call":"engine.execute","params":{"command":"config.row","params":{"name":"Default","values":{"param:span":"20 mm"}}}} + ])) + .unwrap(); + harness.frames(4); + for label in ["Configuration", "Active", "Apply", "Add Row", "Add Column"] { + assert_single_line(&harness, label); + } + crate::scenario::check(&mut harness, &json!({"windows_fit":{"max_width":560}})).unwrap(); + for name in ["span", "thickness", "depth", "clearance"] { + harness.call("engine.execute", json!({"command":"config.column","params":{"param":name,"delete":true}})); + } + harness.call("engine.execute", json!({"command":"config.column","params":{"appearance":"Block","delete":true}})); + assert_single_line(&harness, "Material: Block"); + harness.call("engine.execute", json!({"command":"config.column","params":{"material":"Block","delete":true}})); + harness.call("engine.execute", json!({"command":"config.column","params":{"feature":"Box1"}})); + assert_single_line(&harness, "Suppress: Box1"); + } + fn assert_single_line(harness: &crate::scenario::Harness, label: &str) { + let tree = harness.output.accesskit_update.as_ref().unwrap(); + let bounds = tree + .nodes + .iter() + .filter(|(_, n)| n.label() == Some(label) || n.value() == Some(label)) + .filter_map(|(_, n)| n.bounds()) + .collect::>(); + assert!( + !bounds.is_empty(), + "missing {label}; labels/values: {:?}", + tree.nodes.iter().filter_map(|(_, n)| n.label().or(n.value())).collect::>() + ); + assert!(bounds.iter().all(|b| b.y1 - b.y0 <= 24.0), "{label} wrapped: {bounds:?}"); + } + } + #[test] fn study_keys_by_joint_id() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index e6536edb..55a6bde0 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -360,7 +360,12 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel } row_label(ui, ""); if ui.button("Export DXF…").clicked() { - *status = Some(export_dxf(app, v["body"].as_str().unwrap_or(""))); + *status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "dxf")); + } + ui.end_row(); + row_label(ui, ""); + if ui.button("Export Bend Table…").clicked() { + *status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "csv")); } ui.end_row(); if let Some(s) = status { @@ -381,12 +386,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel enter } -fn export_dxf(app: &mut SolveApp, body: &str) -> String { - let name = format!("{}-{body}.dxf", app.session.doc.name); - let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &["dxf"])) else { +fn export_flat(app: &mut SolveApp, body: &str, extension: &str) -> String { + let name = format!("{}-{body}.{extension}", app.session.doc.name); + let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[extension])) else { return "Export cancelled".into(); }; - match app.run("sheet.export_dxf", json!({"body": body, "path": path})) { + let command = if extension == "csv" { "sheet.export_bend_table" } else { "sheet.export_dxf" }; + match app.run(command, json!({"body": body, "path": path})) { Ok(_) => { app.set_status(format!("Exported {path}"), false); format!("Saved {path}") diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index e4653ee6..9d6325ab 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -106,3 +106,46 @@ fn hem_and_rules() { run_all(&mut app, c); assert!(app.session.doc.sheet.rules.iter().any(|r| r.name == "Alu")); } + +#[test] +fn flat_exports_use_the_save_picker() { + use std::sync::{Arc, Mutex}; + let mut app = plate(); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + let picks = Arc::new(Mutex::new(Vec::new())); + let captured = picks.clone(); + app.services.pick_save = Some(Box::new(move |name, extensions| { + captured.lock().unwrap().push((name.to_string(), extensions.iter().map(|x| x.to_string()).collect::>())); + None + })); + assert_eq!(export_flat(&mut app, "Base", "csv"), "Export cancelled"); + assert_eq!(export_flat(&mut app, "Base", "dxf"), "Export cancelled"); + let picks = picks.lock().unwrap(); + assert!(picks[0].0.ends_with("-Base.csv")); + assert_eq!(picks[0].1, vec!["csv"]); + assert!(picks[1].0.ends_with("-Base.dxf")); + assert_eq!(picks[1].1, vec!["dxf"]); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn bend_table_picker_writes_the_current_body() { + let mut app = plate(); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + app.run("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})).unwrap(); + let body = app.session.model.state().sheets[0].body.clone(); + let path = std::env::temp_dir().join(format!("solvecraft-bend-table-{}.csv", std::process::id())); + let output = path.to_string_lossy().into_owned(); + let chosen = output.clone(); + app.services.pick_save = Some(Box::new(move |_, extensions| { + assert_eq!(extensions, &["csv"]); + Some(chosen.clone()) + })); + let undo = app.session.undo.len(); + assert_eq!(export_flat(&mut app, &body, "csv"), format!("Saved {output}")); + let contents = std::fs::read_to_string(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + assert_eq!(contents.lines().count(), 2); + assert!(contents.contains(",90.000000000,up,2.500000000,"), "{contents}"); + assert_eq!(app.session.undo.len(), undo); +} diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 3829dd59..64c4a7be 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -45,6 +45,32 @@ fn empty_space_offers_view_items_and_greys_out_undo() { assert!(!app.ui.show_origin); } +#[test] +fn appearance_browser_action_targets_the_clicked_body() { + let mut app = sample_app(); + let b = body(&app); + app.run("select.clear", json!({})).unwrap(); + act(&mut app, &Target::Body { name: b.clone() }, "Appearance"); + let dialog = app.dialog.as_ref().unwrap(); + assert!(matches!(dialog.kind, crate::dialogs::Kind::Appearance(_))); + assert_eq!(dialog.inputs[0].items, vec![solvecraft_engine::Sel::Body { name: b }]); +} + +#[test] +fn appearance_component_action_assigns_an_inherited_look() { + let mut app = sample_app(); + let created = app.run("component.create", json!({"name": "Painted part"})).unwrap(); + let id = created["component"].as_u64().unwrap(); + act(&mut app, &Target::Component { id }, "Appearance"); + let d = app.dialog.clone().unwrap(); + let commands = crate::dialogs::apply_commands(&app, &d).unwrap(); + assert_eq!(commands[0].1["components"], json!(["Painted part"])); + for (id, p) in commands { + app.run(&id, p).unwrap(); + } + assert!(app.session.doc.appearances.components.contains_key(&id)); +} + #[test] fn face_menu_hides_and_finds_the_body() { let mut app = sample_app(); @@ -54,7 +80,7 @@ fn face_menu_hides_and_finds_the_body() { for l in ["Create Sketch", "Offset Plane", "Press Pull", "Extrude", "Measure", "Find in Browser", "Find in Timeline"] { assert!(find(&items, l).enabled, "{l}"); } - assert!(!find(&items, "Appearance").enabled); + assert!(find(&items, "Appearance").enabled); context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0)); assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }])); context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0)); @@ -97,6 +123,24 @@ fn properties_measure_the_body() { assert!(v["total"]["mass_g"].as_f64().unwrap() > 0.0); } +#[test] +fn component_properties_measure_one_assembly_placement() { + let mut session = Session::default(); + let component = session.execute("component.create", &json!({"name": "Subassembly"})).unwrap()["component"].as_u64().unwrap(); + session.execute("component.create", &json!({"name": "Block"})).unwrap(); + session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap(); + session.execute("occurrence.copy", &json!({"component": component, "translate": [100, 0, 0]})).unwrap(); + let mut app = SolveApp::new(session, Services::default()); + act(&mut app, &Target::Component { id: component }, "Properties"); + let (title, properties) = app.menu.props.as_ref().unwrap(); + assert_eq!(title, "Subassembly"); + assert_eq!(properties["body_count"], 1); + assert!((properties["total"]["volume_mm3"].as_f64().unwrap() - 1000.0).abs() < 1e-6); + assert!((properties["total"]["center_of_mass"][0].as_f64().unwrap() - 5.0).abs() < 1e-6); + act(&mut app, &Target::Component { id: 0 }, "Properties"); + assert_eq!(app.menu.props.as_ref().unwrap().1["body_count"], 2); +} + #[test] fn sketch_menu_hides_and_moves_to_another_plane() { let mut app = sample_app(); diff --git a/crates/ui-egui/src/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs new file mode 100644 index 00000000..88648237 --- /dev/null +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -0,0 +1,63 @@ +use super::*; +use solvecraft_engine::sketch::CurveKind; + +#[test] +fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() { + for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] { + let mut h = crate::scenario::Harness::new(); + h.app.ui.show_origin = true; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]})).unwrap(); + h.frames(2); + let sid = h.app.session.active_sketch.unwrap(); + let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap()); + let cursor = proj.to_screen(Vec3::ZERO).unwrap(); + // The ordinary snap still finds the fixed origin; the corner tool must instead + // reach the rectangle's overlapping endpoint. + assert_eq!(sketch_point_at(&h.app, &proj, cursor).unwrap().1.as_deref(), Some("origin")); + h.step(&json!({"start":command})).unwrap(); + h.step(&json!({"move":{"world":[0,0,0]}})).unwrap(); + let point = h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref().unwrap(); + assert_ne!(point, "origin", "the actual shared endpoint must be highlighted"); + h.step(&json!({"click":{"world":[0,0,0]}})).unwrap(); + let st = h.app.session.world_state(); + let ss = st.sketch(sid).unwrap(); + assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO)); + assert!(ss.sketch.points[0].fixed); + assert_eq!(ss.sketch.curves.len(), 5, "{command} must actually modify the geometry; status: {:?}", h.app.status); + if command == "sketch.fillet" { + assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. }))); + } else { + assert!(ss.sketch.curves.iter().all(|c| matches!(c.kind, CurveKind::Line { .. }))); + } + let expected = if command == "sketch.fillet" { 600.0 - 25.0 * (1.0 - std::f64::consts::PI / 4.0) } else { 587.5 }; + assert_eq!(ss.profiles.len(), 1); + assert!((ss.profiles[0].area - expected).abs() < 1e-6, "{command}: {}", ss.profiles[0].area); + h.app.run("edit.undo", json!({})).unwrap(); + let st = h.app.session.world_state(); + let ss = st.sketch(sid).unwrap(); + assert_eq!(ss.sketch.curves.len(), 4); + assert!((ss.profiles[0].area - 600.0).abs() < 1e-6); + } +} + +#[test] +fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() { + let mut h = crate::scenario::Harness::new(); + h.app.ui.show_origin = true; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.step(&json!({"start":"sketch.line"})).unwrap(); + h.step(&json!({"move":{"world":[0,0,0]}})).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("origin")); + h.step(&json!({"click":{"world":[0,0,0]}})).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().pts[0].1.as_deref(), Some("origin")); + h.app.tool = None; + h.app.run("sketch.line", json!({"points":["origin",[20,0],[20,20],[0,20],"origin"]})).unwrap(); + h.step(&json!({"start":"sketch.fillet"})).unwrap(); + h.step(&json!({"click":{"world":[0,0,0]}})).unwrap(); + let st = h.app.session.world_state(); + let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO)); + assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. }))); + assert_eq!(ss.profiles.len(), 1); +} diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index c408ec41..77925592 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -240,6 +240,30 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0)); let lv = live(t, e, px); let focus = std::mem::take(&mut t.dims_focus); + // Egui processes ordinary Tab navigation before these floating text edits are drawn. + // Remember the box that owned the keyboard and restore a focus within this tool after + // drawing, so the last dimension wraps to the first rather than reaching the panels. + let tab = ui.input(|input| { + input.raw.events.iter().find_map(|event| match event { + egui::Event::Key { key: egui::Key::Tab, pressed: true, modifiers, .. } if !modifiers.ctrl && !modifiers.alt && !modifiers.command => { + Some(modifiers.shift) + } + _ => None, + }) + }); + let source = tab.and_then(|_| { + ui.ctx().memory(|memory| { + (0..t.dims.len()) + .find(|i| memory.had_focus_last_frame(egui::Id::new(("sc_dim", i)))) + .or_else(|| (0..t.dims.len()).find(|i| memory.has_focus(egui::Id::new(("sc_dim", i))))) + }) + }); + if source.is_some() { + // Also cancel navigation on the first focused frame, before the text edit's focus + // filter has been installed (e.g. immediately repeated keyboard input). + ui.ctx().memory_mut(|memory| memory.move_focus(egui::FocusDirection::None)); + } + let mut completed = false; let mut any_focused = false; for (i, b) in t.dims.iter_mut().enumerate() { let Some((v, at)) = lv.get(i).copied() else { continue }; @@ -260,9 +284,19 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { let fill = if b.locked { tk.accent_soft } else { tk.panel }; egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0)).on_hover_text(b.label); - crate::params_dialog::complete(ui, &r, &mut b.value); - if r.changed() { + let r = ui + .add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).event_filter(egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + ..Default::default() + })) + .on_hover_text(b.label); + // A completion gets first use of forward Tab. Shift+Tab always moves back; + // the box reached by egui's generic traversal must not complete its text. + let changed = (source.is_none() || (source == Some(i) && tab != Some(true))) && crate::params_dialog::complete(ui, &r, &mut b.value); + completed |= changed && source == Some(i); + if r.changed() || changed { b.locked = true; } if focus && i == 0 { @@ -272,6 +306,21 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option }); }); } + if let Some(source) = source + && !completed + { + let next = if tab == Some(true) { (source + t.dims.len() - 1) % t.dims.len() } else { (source + 1) % t.dims.len() }; + let id = egui::Id::new(("sc_dim", next)); + if let Some(dimension) = t.dims.get(next) { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(dimension.value.chars().count()), + ))); + state.store(ui.ctx(), id); + } + ui.ctx().memory_mut(|memory| memory.request_focus(id)); + } let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let nothing = ui.ctx().memory(|m| m.focused().is_none()); (enter && (any_focused || nothing)).then_some(e) @@ -281,3 +330,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option pub fn dimension_params(entities: &[String], ty: &str, value: &str) -> Value { json!({"entities": entities, "type": ty, "value": value}) } + +#[cfg(test)] +#[path = "sketch_dims_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs new file mode 100644 index 00000000..75c3dfb7 --- /dev/null +++ b/crates/ui-egui/src/sketch_dims_tests.rs @@ -0,0 +1,128 @@ +//! Real keyboard input must stay inside the dimensions of the shape being drawn. + +use serde_json::json; + +use crate::scenario::Harness; + +fn drawing(command: &str) -> Harness { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.step(&json!({"start": command})).unwrap(); + h.step(&json!({"click": {"world": [0, 0, 0]}})).unwrap(); + h.step(&json!({"move": {"world": [20, 10, 0]}})).unwrap(); + assert_focus(&h, 0); + h +} + +fn assert_focus(h: &Harness, index: usize) { + let expected = egui::Id::new(("sc_dim", index)); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(expected), "focus must stay in inline dimension {index}"); +} + +fn tab(h: &mut Harness, backwards: bool) { + h.step(&json!({"key": "Tab", "shift": backwards})).unwrap(); +} + +fn type_value(h: &mut Harness, text: &str) { + h.step(&json!({"key": "A", "cmd": true})).unwrap(); + h.step(&json!({"text": text})).unwrap(); +} + +#[test] +fn tabs_cycle_rectangle_dimensions_indefinitely_and_enter_commits_values() { + for command in ["sketch.rectangle.two_point", "sketch.rectangle.center"] { + let mut h = drawing(command); + type_value(&mut h, "30"); + tab(&mut h, false); + assert_focus(&h, 1); + type_value(&mut h, "12"); + for i in 0..8 { + tab(&mut h, false); + assert_focus(&h, i % 2); + } + for i in 0..8 { + tab(&mut h, true); + assert_focus(&h, i % 2); + } + // Revisited locked dimensions are selected, so another value replaces them directly. + h.step(&json!({"text": "14"})).unwrap(); + tab(&mut h, true); + assert_focus(&h, 0); + h.step(&json!({"text": "32"})).unwrap(); + let dims = &h.app.tool.as_ref().unwrap().dims; + assert_eq!(dims.iter().map(|d| d.value.as_str()).collect::>(), ["32", "14"]); + assert!(dims.iter().all(|d| d.locked)); + h.step(&json!({"key": "Enter"})).unwrap(); + let state = h.app.session.world_state(); + let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert_eq!(sketch.sketch.curves.iter().filter(|curve| !curve.construction).count(), 4); + assert_eq!(sketch.profiles.len(), 1); + assert!((sketch.profiles[0].area - 448.0).abs() < 1e-6, "{command}: {}", sketch.profiles[0].area); + } +} + +#[test] +fn line_dimensions_wrap_both_directions_and_enter_uses_length_and_angle() { + let mut h = drawing("sketch.line"); + type_value(&mut h, "10"); + tab(&mut h, false); + type_value(&mut h, "45 deg"); + for i in 0..6 { + tab(&mut h, false); + assert_focus(&h, i % 2); + } + tab(&mut h, true); + assert_focus(&h, 0); + h.step(&json!({"key": "Enter"})).unwrap(); + let state = h.app.session.world_state(); + let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert_eq!(sketch.sketch.curves.len(), 1); + let points: Vec<_> = sketch.sketch.points.iter().map(|point| point.pos).collect(); + assert!(points.iter().any(|point| (point.x - 10.0 / 2f64.sqrt()).abs() < 1e-6 && (point.y - 10.0 / 2f64.sqrt()).abs() < 1e-6)); +} + +#[test] +fn single_dimension_keeps_focus_for_tab_and_shift_tab() { + let mut h = drawing("sketch.circle.center"); + type_value(&mut h, "8"); + for backwards in [false, false, true, true, false] { + tab(&mut h, backwards); + assert_focus(&h, 0); + } + assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "8"); + h.step(&json!({"key": "Enter"})).unwrap(); + let state = h.app.session.world_state(); + let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert_eq!(sketch.profiles.len(), 1); + assert!((sketch.profiles[0].area - std::f64::consts::PI * 16.0).abs() < 1e-6); +} + +#[test] +fn forward_tab_completes_expression_then_cycles_and_shift_tab_moves_back() { + let mut h = drawing("sketch.rectangle.two_point"); + h.app.run("parameters.add", json!({"name": "width", "expression": "24 mm"})).unwrap(); + h.frames(2); + type_value(&mut h, "wid"); + tab(&mut h, false); + assert_focus(&h, 0); + assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width"); + tab(&mut h, false); + assert_focus(&h, 1); + type_value(&mut h, "5"); + tab(&mut h, true); + assert_focus(&h, 0); + type_value(&mut h, "wid"); + tab(&mut h, true); + assert_focus(&h, 1); + assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "wid", "Shift+Tab must navigate rather than accepting a completion"); + tab(&mut h, true); + assert_focus(&h, 0); + tab(&mut h, false); + assert_focus(&h, 0); + assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width"); + h.step(&json!({"key": "Enter"})).unwrap(); + let state = h.app.session.world_state(); + let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert_eq!(sketch.profiles.len(), 1); + assert!((sketch.profiles[0].area - 120.0).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 39f02e8f..240b7c1f 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -458,6 +458,41 @@ fn sketch_entity(app: &SolveApp, hits: &[Hit]) -> Option<(String, bool)> { }) } +/// Corner tools accept endpoints shared by two editable curves. Pick among those endpoints +/// directly so the origin, a circle centre or an overlapping loose point cannot hide a corner. +pub(super) fn corner_point(app: &SolveApp, proj: &Proj, pos: Pos2, cmd: &str) -> Option<(Vec2, String)> { + let st = app.session.world_state(); + let ss = st.sketch(active_sketch(app)?)?; + let sk = &ss.sketch; + sk.points + .iter() + .enumerate() + .filter_map(|(pi, point)| { + let distance = proj.to_screen(ss.plane.to_world(point.pos))?.distance(pos); + if distance >= 8.0 { + return None; + } + let curves: Vec<_> = sk + .curves + .iter() + .enumerate() + .filter(|(ci, c)| !sk.is_linked_curve(*ci) && c.kind.ends().is_some_and(|(a, b)| a == pi || b == pi)) + .take(3) + .collect(); + if curves.len() != 2 + || curves.iter().any(|(_, c)| { + !matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. }) + && (cmd != "sketch.fillet" || !matches!(c.kind, solvecraft_engine::sketch::CurveKind::Arc { .. })) + }) + { + return None; + } + Some((distance, point.pos, point.id.clone())) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, at, id)| (at, id)) +} + fn xy(p: Vec2) -> Value { json!([p.x, p.y]) } @@ -624,6 +659,12 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } Mode::Entities(n) => { + if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") { + if let Some((_, id)) = corner_point(app, proj, pos, cmd) { + run_corner(app, cmd, &id); + } + return; + } if cmd == "sketch.offset" && !tool.picks.is_empty() { if let Some((p, _)) = at { offset_side(app, tool, p); @@ -640,12 +681,6 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } return; } - if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") { - if is_point { - run_corner(app, cmd, &id); - } - return; - } if cmd == "sketch.centerline" { let _ = app.run(cmd, json!({"curves": [id]})); return; @@ -1132,3 +1167,7 @@ fn text_entry(app: &mut SolveApp, ctx: &egui::Context) { None => TEXT.with(|t| *t.borrow_mut() = Some((at, text))), } } + +#[cfg(test)] +#[path = "sketch_corner_ui_tests.rs"] +mod corner_ui_tests; diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 681f41ba..174311d8 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -100,7 +100,11 @@ fn xy(p: &(Vec2, Option)) -> Value { pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) { crate::sketch3d::on_hover(app, proj, pos); - let h = sketch_point_at(app, proj, pos); + let h = if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) { + crate::sketch_tools::corner_point(app, proj, pos, tool.cmd).map(|(at, id)| (at, Some(id))) + } else { + sketch_point_at(app, proj, pos) + }; if let Some(t) = app.tool.as_mut() { t.hover = h; } diff --git a/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json b/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json index 3f8cd9a2..e7aa525a 100644 --- a/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json +++ b/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json @@ -110,7 +110,15 @@ "file": "Untitled-Body1.dxf" } }, + { + "widget": "Export Bend Table\u2026" + }, + { + "expect": { + "file": "Untitled-Body1.csv" + } + }, { "shot": "s08_flat" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json index 5f2b316e..a039605e 100644 --- a/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json +++ b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json @@ -78,14 +78,10 @@ }, { "expect": { - "sketch_world": { - "sketch": "Sketch1", - "point": "p1", - "at": [ - 140, - 0, - 20 - ] + "linked_world_range": { + "x": [100, 140], + "y": [0, 30], + "z": [20, 20] } } }, @@ -100,4 +96,4 @@ "errors": 0 } } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s60_appearance_overrides.json b/crates/ui-egui/tests/scenarios/s60_appearance_overrides.json new file mode 100644 index 00000000..5aa97296 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s60_appearance_overrides.json @@ -0,0 +1,17 @@ +[ + {"note": "Appearance from the Browser targets its body, filters library names, and removal survives OK and can be undone."}, + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}}, + {"call": "engine.execute", "params": {"command": "appearance.assign", "params": {"bodies": ["Body1"], "color": "#abcdef", "appearance": "Tinted glass", "opacity": 0.25}}}, + {"call": "ui.menu", "params": {"x": 300, "y": 300, "target": {"type": "body", "name": "Body1"}}}, + {"call": "ui.menuPick", "params": {"item": "Appearance"}}, + {"expect": {"dialog": "Appearance", "shown": "Remove override"}}, + {"click": {"handle": "appearance:search"}}, + {"text": "no-such-appearance"}, + {"expect": {"shown": "No matching appearances"}}, + {"shot": "appearance_library_search"}, + {"widget": "Remove override"}, + {"widget": "OK"}, + {"expect": {"dialog": null, "query": {"command": "appearance.list", "path": ["bodies"], "value": {}}}}, + {"call": "engine.execute", "params": {"command": "edit.undo", "params": {}}}, + {"expect": {"query": {"command": "appearance.list", "path": ["bodies", "Body1", "opacity"], "value": 0.25}}} +] diff --git a/crates/ui-egui/tests/scenarios/s80_configuration_styles.json b/crates/ui-egui/tests/scenarios/s80_configuration_styles.json new file mode 100644 index 00000000..e1e07416 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s80_configuration_styles.json @@ -0,0 +1,21 @@ +[ + {"note":"Configure a body's appearance in the table, apply the edited active row, and render the result."}, + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":20,"height":10,"body_name":"Block"}}}, + {"call":"engine.execute","params":{"command":"material.assign","params":{"bodies":["Block"],"material":"Steel"}}}, + {"start":"config.table"}, + {"widget":"Add Column"}, + {"widget":"Block (appearance)"}, + {"expect":{"shown":"Appearance: Block"}}, + {"widget":"Library"}, + {"widget":"ABS - Red"}, + {"widget":"Apply"}, + {"shot":"configuration_red"}, + {"expect":{"errors":0,"bodies":1,"query":{"command":"appearance.list","path":["bodies","Block","color"],"value":"#c42828"}}}, + {"widget":"Library"}, + {"widget":"Follow material"}, + {"widget":"Apply"}, + {"shot":"configuration_default"}, + {"expect":{"errors":0,"bodies":1,"query":{"command":"appearance.list","path":["bodies","Block"],"value":null}}}, + {"expect":{"query":{"command":"appearance.list","params":{"body":"Block"},"path":["body","appearance","name"],"value":"Steel"}}}, + {"expect":{"query":{"command":"config.table","path":["matches"],"value":true}}} +] diff --git a/docs/assembly-properties.md b/docs/assembly-properties.md new file mode 100644 index 00000000..588f5cc4 --- /dev/null +++ b/docs/assembly-properties.md @@ -0,0 +1,33 @@ +# Assembly physical properties + +The Browser's component **Properties** action measures the selected assembly placement, +including its nested components. Select the root design to measure every instance in the +assembly. The window shows material-aware mass, volume, surface area, center of mass and +the world bounding box. Pending occurrence moves are included without capturing them. + +The same measurements are available through commands: + +```json +{"command":"component.properties","params":{"component":"Bracket"}} +{"command":"component.properties","params":{"component":"root"}} +{"command":"component.properties","params":{"component":"Subassembly","descendants":false}} +{"command":"occurrence.properties","params":{"occurrence":"Bracket:2"}} +``` + +`component.properties` defaults to the active component and measures its first placement. +`occurrence.properties` measures the chosen instance. A nested occurrence can appear beneath +several instances of its parent; supply `path` (the complete list of occurrence ids from the +root) to choose one of those placements. An ambiguous occurrence without a path returns an +error. Both commands accept `descendants: false` to measure only directly owned bodies. + +Results contain a `bodies` list and a `total` object. Each body's entry identifies the original +body name, owning component and occurrence path. Physical material density is resolved from +that original body, so copies keep their material and mass. Values use millimetres, square +millimetres, cubic millimetres and grams; coordinates use the world frame. + +Mass and center of mass account for each body's density. Volume and area are summed over +placed bodies, including overlapping bodies and repeated instances. Measurements use the +kernel's tessellation-based body measurement. An empty component reports zero totals and +null center of mass and bounding box. A failed body measurement returns an error instead of +presenting an incomplete assembly total. These commands leave geometry, pending placements +and undo history unchanged. diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 94aefac0..e837086b 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -20,3 +20,12 @@ couples two joints. Contact sets stop motion where bodies would pass through eac volumes in red. STEP export writes the component tree as an assembly. + +Right-click a component in the Browser and choose **Properties** to inspect +material-aware mass, volume, surface area, centre of mass and world bounds. Descendants and +repeated instances contribute to assembly totals; pending moves are included. A component +uses its first placement; `occurrence.properties` selects a specific instance. Root Properties +measures the complete assembly. Volumes are summed, including overlapping bodies, rather than +measuring their union. The same workflow is available through `component.properties` and +`occurrence.properties`; see +[placement and measurement details](https://github.com/bherbruck/solvecraft/blob/main/docs/assembly-properties.md). diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index b3da3ecb..c9d0ea70 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -18,4 +18,8 @@ existing geometry. Parameters export to and import from CSV or JSON (`parameters.export`, `parameters.import`). **Configurations** (`config.*`) keep a table of rows, each with its own parameter values and -suppressed features, switched with `config.activate`. +suppressed features, physical materials and body appearances, switched with `config.activate`. +The table's Library picker selects material or appearance values. Choose **Follow material** +to clear an appearance override. Custom looks retain their colour and opacity across +activation, undo and save/reopen. See +[configuration commands and examples](https://github.com/bherbruck/solvecraft/blob/main/docs/configurations.md). diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 78d5c7de..b38a8e15 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -9,7 +9,22 @@ The SHEET METAL tab builds parts from a sheet of constant thickness under a **sh - **Unfold / Refold** (`sheet.unfold`, `sheet.refold`) for working on the flat part. - **Convert** (`sheet.convert`) turns a solid of constant thickness into sheet metal. - **Create Flat Pattern** (`sheet.flat_pattern`) shows the flat part with its bend lines and a - bend table, and exports it as DXF. + bend table, and exports it as DXF or a bend-table CSV through the save picker. Bends follow the rule's K-factor, so flat lengths match what a press brake will make. The oracle checks these against parts built in Fusion. + +For a shop drawing or spreadsheet, `sheet.export_bend_table` returns CSV by default; choose +`format: "json"` for structured data. It includes the sheet rule, thickness, bend angles, +radii, allowances and flat centre-line coordinates. Lengths use millimetres and angles use +degrees. Supply `path` to save it, or omit `path` to receive the content through the command +response. `body` defaults to the last sheet body. Exporting does not add a timeline entry or +consume an undo step. + +```json +{"command":"sheet.export_bend_table","params":{"format":"csv","path":"bracket-bends.csv"}} +{"command":"sheet.export_dxf","params":{"path":"bracket-flat.dxf"}} +``` + +DXF cutout geometry preserves lines, circles and circular arcs; other curves are sampled +adaptively. The `OUTLINE`, `BEND_UP` and `BEND_DOWN` layers separate cuts from bend centres. diff --git a/docs/configurations.md b/docs/configurations.md new file mode 100644 index 00000000..43141b28 --- /dev/null +++ b/docs/configurations.md @@ -0,0 +1,43 @@ +# Design configurations + +The configuration table stores named design variants. Add parameter, feature suppression, +physical material, and body appearance columns with `config.configure` or `config.column`. +The desktop Configuration Table's **Add Column** menu offers the same targets. Material and +appearance cells have a **Library** picker; their text fields also accept exact saved values. + +```json +{"command":"config.configure","params":{"params":["width"],"materials":["Box1"],"appearances":["Box1"]}} +{"command":"config.row","params":{"name":"Aluminum blue","values":{"param:width":"80 mm","material:Box1":"Aluminum","appearance:Box1":"Aluminum - Anodized Blue"}}} +{"command":"config.activate","params":{"row":"Aluminum blue"}} +``` + +Use the body's actual Browser name in place of `Box1`. `config.table` returns the column keys, +rows, active name, and `matches`: whether the current design still has the active row's values. +Changing an assignment by hand makes `matches` false. Activating another row is undoable. + +Material values are names from `material.list` (case insensitive), with `Default` clearing the +body's physical material assignment. Appearance values are names from `appearance.library`, +an empty string or `Default` to clear the body's appearance, or a custom look object: + +```json +{"command":"config.row","params":{"name":"Transparent blue","from":"Aluminum blue","values":{"appearance:Box1":{"name":"Custom blue","color":[40,90,186],"opacity":0.4}}}} +``` + +The table stores custom looks without losing colour or opacity. `color` contains three byte +values, names support up to 128 Unicode characters, and `opacity` must be finite and between +zero and one. Style values are validated when +editing a row and again when activating a saved row. An invalid value leaves the design unchanged. +Clearing a body appearance allows component appearance or material colour to show through; +face appearances keep their existing priority. Physical material changes affect measured mass. + +`config.row` supports `from` to copy another row, `rename` to rename an existing row, and +`delete` to remove it. Names must be unique. To remove a style column, use +`config.column` with `material` or `appearance` set to the target body and `delete: true`. + +Style columns target body names. Assignments remain stored when a row suppresses the feature +that produces that body, and become visible when it returns. Component and face appearance +columns and conditional configuration rules are not yet supported. + +Designs saved with these columns use file format 7. Older designs upgrade on opening without +changing their existing configuration values. Older SolveCraft versions must be updated to +open newly saved designs. diff --git a/docs/contact-sets.md b/docs/contact-sets.md new file mode 100644 index 00000000..bc240e27 --- /dev/null +++ b/docs/contact-sets.md @@ -0,0 +1,34 @@ +# Contact sets and joint motion + +Contact sets constrain **Drive Joints**, motion-study playback and exported motion samples. +Bodies belonging to different members of an enabled set are checked along the requested +joint motion. A collision between clear start and end poses can therefore stop the motion, +including slider travel through a blocker and revolute travel across one. + +```json +{"command":"contact.create","params":{"occurrences":["Base:1","Carriage:1"]}} +{"command":"joint.drive","params":{"joint":"Rail","value":30}} +``` + +A stopped command returns `stopped_by_contact`, identifying the occurrence pair and the +fraction of the requested motion reached. The first detected collision interval is refined +to leave the bodies just short of penetration, within the tessellated contact tolerance. +Motion links and limits participate in the trajectory checks. Moving a body between +components preserves its placement for contact checks through its body offset. + +The trajectory uses bounded samples of cached tessellated geometry. Spacing accounts for +effective joint travel, angular motion, distance from joint pivots and whole-body widths +along sampled face directions. This handles tilted thin plates while keeping curved bodies +practical to drive. It is not exact continuous collision detection: locally thin walls in +a larger body, unsampled face directions and very small geometric details remain limitations. + +When a motion or assembly exceeds the sampling, geometry or collision-work budget, the +command returns an error and restores the document. Use smaller drive increments or a +smaller contact set. Mesh bodies in contact sets are currently rejected for swept driving. +An assembly already overlapping at the start retains the recovery behaviour that allows +it to move out of that invalid placement. Disable contact to move freely. + +`motion.export` uses the same checks between its requested poses, including sparse exports, +and leaves the source document unchanged. Contact checks interpolate joint values between +those poses; they do not reconstruct intermediate motion-study keyframes omitted by the +requested sample steps. diff --git a/docs/parity.md b/docs/parity.md index 02a7a78e..32d6be9e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,9 +2,9 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 328 / 541 commands live (61%).** SOLID + SKETCH: 185 / 291 (64%). Registered commands: 300. +**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 331 / 541 commands live (61%).** SOLID + SKETCH: 186 / 291 (64%). Registered commands: 307. -Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 616 / 1175 (52%). +Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 623 / 1175 (53%). | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| @@ -12,7 +12,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SOLID | MODIFY | 19 | 32 | 59% | | SOLID | ASSEMBLE | 12 | 21 | 57% | | SOLID | CONFIGURE | 2 | 3 | 67% | -| SOLID | CONSTRUCT | 18 | 20 | 90% | +| SOLID | CONSTRUCT | 19 | 20 | 95% | | SOLID | INSPECT | 12 | 25 | 48% | | SOLID | INSERT | 6 | 11 | 55% | | SOLID | SELECT | 0 | 15 | 0% | @@ -30,7 +30,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SHEET METAL | MODIFY | 12 | 21 | 57% | | SHEET METAL | ASSEMBLE | 11 | 20 | 55% | | SHEET METAL | CONFIGURE | 2 | 3 | 67% | -| SHEET METAL | CONSTRUCT | 18 | 20 | 90% | +| SHEET METAL | CONSTRUCT | 19 | 20 | 95% | | SHEET METAL | INSPECT | 12 | 25 | 48% | | SHEET METAL | INSERT | 6 | 11 | 55% | | SHEET METAL | SELECT | 0 | 15 | 0% | @@ -43,7 +43,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | ASSEMBLY | MODIFY | 8 | 11 | 73% | | ASSEMBLY | CONTACT | 2 | 2 | 100% | | ASSEMBLY | CONFIGURE | 2 | 3 | 67% | -| ASSEMBLY | CONSTRUCT | 18 | 20 | 90% | +| ASSEMBLY | CONSTRUCT | 19 | 20 | 95% | | ASSEMBLY | INSPECT | 12 | 25 | 48% | | ASSEMBLY | SELECT | 0 | 15 | 0% | | ASSEMBLY | POSITION | 2 | 2 | 100% | @@ -57,7 +57,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SURFACE | MODIFY | 17 | 26 | 65% | | SURFACE | ASSEMBLE | 11 | 20 | 55% | | SURFACE | CONFIGURE | 2 | 3 | 67% | -| SURFACE | CONSTRUCT | 18 | 20 | 90% | +| SURFACE | CONSTRUCT | 19 | 20 | 95% | | SURFACE | INSPECT | 12 | 25 | 48% | | SURFACE | INSERT | 6 | 11 | 55% | | SURFACE | SELECT | 0 | 15 | 0% | @@ -67,7 +67,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | MESH | MODIFY | 6 | 30 | 20% | | MESH | ASSEMBLE | 12 | 21 | 57% | | MESH | CONFIGURE | 2 | 3 | 67% | -| MESH | CONSTRUCT | 18 | 20 | 90% | +| MESH | CONSTRUCT | 19 | 20 | 95% | | MESH | INSPECT | 12 | 25 | 48% | | MESH | INSERT | 5 | 10 | 50% | | MESH | SELECT | 0 | 10 | 0% | @@ -77,7 +77,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | FORM | MODIFY | 5 | 31 | 16% | | FORM | SYMMETRY | 0 | 6 | 0% | | FORM | UTILITIES | 0 | 5 | 0% | -| FORM | CONSTRUCT | 18 | 20 | 90% | +| FORM | CONSTRUCT | 19 | 20 | 95% | | FORM | INSPECT | 12 | 25 | 48% | | FORM | INSERT | 7 | 13 | 54% | | FORM | ASSEMBLE | 0 | 1 | 0% | @@ -88,7 +88,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | PLASTIC | MODIFY | 19 | 32 | 59% | | PLASTIC | ASSEMBLE | 11 | 20 | 55% | | PLASTIC | CONFIGURE | 2 | 3 | 67% | -| PLASTIC | CONSTRUCT | 18 | 20 | 90% | +| PLASTIC | CONSTRUCT | 19 | 20 | 95% | | PLASTIC | INSPECT | 12 | 26 | 46% | | PLASTIC | INSERT | 6 | 11 | 55% | | PLASTIC | SELECT | 0 | 15 | 0% | @@ -170,6 +170,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › CONSTRUCT › Plane at Angle - SOLID › CONSTRUCT › Tangent Plane - SOLID › CONSTRUCT › Midplane +- SOLID › CONSTRUCT › Perpendicular Plane - SOLID › CONSTRUCT › Plane Through Two Edges - SOLID › CONSTRUCT › Plane Through Three Points - SOLID › CONSTRUCT › Plane Along Path @@ -333,7 +334,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › ASSEMBLE › As-Built Constraint - SOLID › CONFIGURE › Create Configuration Rules - SOLID › CONSTRUCT › User Coordinate System (UCS) -- SOLID › CONSTRUCT › Perpendicular Plane - SOLID › INSPECT › Validate - SOLID › INSPECT › Component Connection Analysis - SOLID › INSPECT › DOF Analysis From 07932d0f42d65097f583aa69df496b59a54fd908 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sun, 11 Oct 2026 01:28:01 -0400 Subject: [PATCH 2/5] feat: preserve modeling intent across sketches, assemblies, and fabrication Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands. Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected. --- ROADMAP.md | 35 +- crates/doc/src/appearance.rs | 8 +- crates/doc/src/assembly.rs | 49 +- crates/doc/src/config.rs | 150 +++- crates/doc/src/config_rules.rs | 646 ++++++++++++++++++ crates/doc/src/document.rs | 35 +- crates/doc/src/eval.rs | 59 +- crates/doc/src/eval_more.rs | 35 +- crates/doc/src/format.rs | 68 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/params.rs | 74 ++ crates/doc/src/sheet.rs | 518 ++++++++++---- crates/engine/src/cmd/browser.rs | 70 +- crates/engine/src/cmd/component.rs | 233 ++++++- crates/engine/src/cmd/component_properties.rs | 39 +- crates/engine/src/cmd/config.rs | 281 +++++++- crates/engine/src/cmd/delete.rs | 9 +- crates/engine/src/cmd/edit.rs | 3 + crates/engine/src/cmd/inspect.rs | 15 +- crates/engine/src/cmd/occurrence_tests.rs | 421 ++++++++++++ crates/engine/src/cmd/sheet.rs | 35 +- crates/engine/src/cmd/sheet_tests.rs | 325 +++++++++ crates/engine/src/cmd/sketch.rs | 38 +- crates/engine/src/cmd/sketch_constraints.rs | 5 +- crates/engine/src/cmd/sketch_dimensioned.rs | 114 ++++ .../src/cmd/sketch_dimensioned_tests.rs | 167 +++++ crates/engine/src/frames.rs | 38 +- crates/engine/src/lib.rs | 39 +- crates/engine/src/occurrences.rs | 216 ++++++ crates/engine/src/view.rs | 15 +- crates/kernel/src/body.rs | 103 ++- crates/kernel/src/body_lump_tests.rs | 224 ++++++ crates/kernel/src/lib.rs | 1 + crates/kernel/src/ops.rs | 50 +- crates/kernel/src/prism.rs | 306 ++++++++- crates/kernel/src/sew.rs | 134 +++- crates/kernel/src/shell_groups.rs | 222 ++++++ crates/sketch/src/dims.rs | 20 + crates/sketch/src/model.rs | 77 ++- crates/sketch/src/solver.rs | 128 +++- crates/ui-egui/src/access.rs | 2 +- crates/ui-egui/src/browser.rs | 230 ++++++- crates/ui-egui/src/configuration_rules_ui.rs | 356 ++++++++++ crates/ui-egui/src/context_menu.rs | 104 ++- crates/ui-egui/src/control.rs | 11 + crates/ui-egui/src/delete.rs | 18 +- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs_appearance.rs | 4 +- crates/ui-egui/src/dialogs_motion.rs | 114 +++- crates/ui-egui/src/documents.rs | 2 + crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/menu_tests.rs | 20 + crates/ui-egui/src/occurrence_ui_tests.rs | 182 +++++ crates/ui-egui/src/sketch_dims.rs | 87 ++- crates/ui-egui/src/sketch_tools.rs | 1 + .../ui-egui/src/sketch_typed_intent_tests.rs | 184 +++++ crates/ui-egui/src/tools.rs | 93 ++- crates/ui-egui/src/viewport.rs | 42 +- .../s34_component_move_carries_sketches.json | 4 +- .../scenarios/s61_occurrence_browser.json | 22 + docs/book/src/guide/sheet-metal.md | 10 + docs/configurations.md | 62 +- docs/occurrence-workflows.md | 39 ++ docs/parity.md | 22 +- xtask/data/fusion-ids.tsv | 1 + 65 files changed, 6145 insertions(+), 484 deletions(-) create mode 100644 crates/doc/src/config_rules.rs create mode 100644 crates/engine/src/cmd/occurrence_tests.rs create mode 100644 crates/engine/src/cmd/sketch_dimensioned.rs create mode 100644 crates/engine/src/cmd/sketch_dimensioned_tests.rs create mode 100644 crates/engine/src/occurrences.rs create mode 100644 crates/kernel/src/body_lump_tests.rs create mode 100644 crates/kernel/src/shell_groups.rs create mode 100644 crates/ui-egui/src/configuration_rules_ui.rs create mode 100644 crates/ui-egui/src/occurrence_ui_tests.rs create mode 100644 crates/ui-egui/src/sketch_typed_intent_tests.rs create mode 100644 crates/ui-egui/tests/scenarios/s61_occurrence_browser.json create mode 100644 docs/occurrence-workflows.md diff --git a/ROADMAP.md b/ROADMAP.md index bed9e2f7..a54854aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,9 +7,9 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat | Question | Answer | |---|---| -| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. | -| Command parity (in scope) | **331 / 541 (61%)** in-scope commands; SOLID + SKETCH **186 / 291 (64%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity | -| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) | +| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. | +| Command parity (in scope) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity | +| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. | | Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green | | Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) | @@ -46,7 +46,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. | # | Milestone | Status | Hours left | |---|---|---|---| | M0 | Vertical slice | done | — | -| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 | +| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Remaining workflow repairs: exact free-form Extend intersections, associative free-form Offset, and consistent occurrence-frame 3D wire authoring | in progress | 4 | | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 | @@ -58,8 +58,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | -| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Left: configuration rules and component/face style columns | in progress | 4 | -| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/7"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Left: component/face style columns | in progress | 4 | +| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/8"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | ## Known limitations @@ -84,7 +84,10 @@ headline counts in-scope tabs only and lists the deferred ones separately. sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face (a part and its mirror image) are stitched; coincident planar faces are pushed apart when their neighbours are planes or cylinders. Other coincident curved configurations can still - fail. Fully internal voids are not supported. Spheres are built from six pole-free patches. + fail. Bounded containment classification preserves fully internal cavities and nested + material islands independently of shell order, with STEP round-trip and material-membership + regressions. Near-contact curved shells use the general boolean path rather than a + containment shortcut. Spheres are built from six pole-free patches. - Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles that cut through curved faces (trimmed faces after booleans) are split onto the surface first. - STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass @@ -117,6 +120,24 @@ headline counts in-scope tabs only and lists the deferred ones separately. - Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes, selecting visited values for replacement. Expression completion retains forward-Tab priority; real keyboard regressions cover rectangles, lines and single-value circles. + Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints + and expressions through save/reopen. Shape creation and its dimensions form one undo step; + rejected values keep the editable field focused. +- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use + checked occurrence paths. Active nested occurrences author planar sketches and solids in + their selected frame. Pending moves compose through parents; dialog cancellation restores + its selected move. Source materials and body/face appearances follow transformed instances. + Shared nested definitions remain shared, and legacy 3D wire authoring and deep Paste New + need further work. See [occurrence workflows](docs/occurrence-workflows.md). +- Configuration rules: ordered typed conditions can drive parameters and suppressions or + restrict values. Preview explains effective values without modifying the document; applying + rules validates rows atomically. Length expressions retain their authoring units across + document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering. + Component and face style columns remain open. See [configurations](docs/configurations.md). +- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained + islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material + pieces survive unfold/refold as one logical sheet group, including split flanged sheets. + Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling. - Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index f5f8b431..89fcfce7 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -130,6 +130,10 @@ pub fn material_look(material: &str) -> Option { } impl Document { + /// Shared definition appearance for a computed occurrence body. + pub fn model_body_look(&self, body: &crate::ModelBody) -> Option { + self.body_look(body.definition_name(), body.feature) + } /// A body's look (without face appearances): its own, else its component's, else its /// material's. pub fn body_look(&self, name: &str, feature: u64) -> Option { @@ -167,8 +171,8 @@ impl Document { None => Vec::new(), }, }; - for f in self.appearances.faces.iter().filter(|f| f.body == b.name) { - let (p, look) = (f.point, f.look.clone()); + for f in self.appearances.faces.iter().filter(|f| f.body == b.definition_name()) { + let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone()); // By name (every piece of a split face), else by the point. if let Some(n) = &f.name { let stem = crate::naming::strip_piece(n); diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 771d31a3..ece7c690 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -385,24 +385,61 @@ impl Document { /// further instance), its component, the occurrence path, and the transform from the frame /// its geometry is in to the world. pub fn body_frames(&self, st: &crate::ModelState) -> Vec<(String, u64, Vec, Mat)> { + self.body_frames_with_source(st).into_iter().map(|(world, _, component, path, matrix)| (world, component, path, matrix)).collect() + } + + /// Body frames with explicit definition names; callers must not infer them from labels. + pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec, Mat)> { let mut out = Vec::new(); for (comp, path, m) in self.placements() { - let primary = path == self.first_path(comp); - let label = || -> String { - path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::>().join("/") - }; for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) { - let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) }; + let name = self.world_body_name(st, &b.name, comp, &path); let w = match self.body_offsets.get(&b.name) { Some(o) => mat_mul(&m, o), None => m, }; - out.push((name, comp, path.clone(), w)); + out.push((name, b.definition_name().to_string(), comp, path.clone(), w)); } } out } + /// Computed placement name. Source names retain priority; ambiguous labels use full IDs. + /// Resolves label ambiguity with bounded dynamic counts instead of expanding all placements. + pub fn world_body_name(&self, st: &crate::ModelState, source: &str, component: u64, path: &[u64]) -> String { + if path == self.first_path(component) { + return source.to_string(); + } + let names: Vec<&str> = path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect(); + let label = names.join("/"); + let candidate = format!("{source} ({label})"); + let mut counts = std::collections::BTreeMap::from([(0, 1usize)]); + for name in &names { + let mut next = std::collections::BTreeMap::::new(); + for o in self.occurrences.iter().filter(|o| o.name == *name) { + if let Some(count) = counts.get(&o.parent) { + let n = next.entry(o.component).or_default(); + *n = (*n + count).min(2); + } + } + counts = next; + } + let ambiguous = counts.get(&component).copied().unwrap_or(0) > 1; + // Slash and reserved-marker names could alias concatenated labels or fallback keys. + let reserved = + self.occurrences.iter().any(|o| o.name.contains('/') || o.name.contains("@occ[")) || st.bodies.iter().any(|b| b.name.contains("@occ[")); + if !ambiguous && !reserved && st.body(&candidate).is_none() { + return candidate; + } + let ids = path.iter().map(u64::to_string).collect::>().join("/"); + let encoded = serde_json::to_string(source).unwrap_or_else(|_| source.to_string()); + let mut key = format!("{encoded}@occ[{ids}]"); + while st.body(&key).is_some() { + key.push('#'); + } + key + } + /// The occurrence path of a component's first placement. pub fn first_path(&self, component: u64) -> Vec { let mut path = Vec::new(); diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs index ffd465e4..97fa631c 100644 --- a/crates/doc/src/config.rs +++ b/crates/doc/src/config.rs @@ -6,6 +6,10 @@ use serde::{Deserialize, Serialize}; use crate::{DocError, Document, Result}; +#[path = "config_rules.rs"] +mod rules; +pub use rules::*; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Column { @@ -32,14 +36,24 @@ pub struct ConfigTable { pub rows: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub active: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, } pub const MAX_COLUMNS: usize = 500; pub const MAX_ROWS: usize = 2000; +pub fn suppression_cell(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Ok(true), + "false" | "0" | "no" => Ok(false), + _ => Err(DocError::Invalid("suppression must be true or false".into())), + } +} + impl ConfigTable { pub fn is_empty(&self) -> bool { - self.columns.is_empty() && self.rows.is_empty() + self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty() } } @@ -75,10 +89,46 @@ pub fn material_cell(value: &str) -> Result> { } impl Document { + /// Freeze design-unit-sensitive configuration literals when changing display units. + /// Explicit units and nonliteral expressions retain their source text. + pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) { + let feature_lengths: Vec = self + .features + .iter() + .flat_map(|feature| { + feature + .param_names + .iter() + .zip(feature.kind.inputs()) + .filter(|(_, (_, _, kind))| *kind == crate::expr::Kind::Length) + .map(|(name, _)| name.clone()) + }) + .collect(); + let lengths: std::collections::BTreeSet = feature_lengths.into_iter().chain(kept_parameters.iter().cloned()).collect(); + for row in &mut self.configs.rows { + for (column, cell) in self.configs.columns.iter().zip(&mut row.cells) { + if matches!(column,Column::Param(name) if lengths.contains(name)) && cell.trim().parse::().is_ok() { + *cell = format!("{} {previous_unit}", cell.trim()); + } + } + } + for rule in &mut self.configs.rules { + rule.preserve_length_units(&lengths, previous_unit); + } + } + /// A column's current value in the design. pub fn config_current(&self, c: &Column) -> Option { match c { - Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e), + Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, kind)| { + // Feature literals are stored in base units, unlike newly typed bare + // row cells. Capture their unit explicitly before editing in an inch design. + if self.param(n).is_none() && kind == crate::expr::Kind::Length && self.units != "mm" && e.trim().parse::().is_ok() { + format!("{} mm", e.trim()) + } else { + e + } + }), Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()), Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())), Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()), @@ -97,27 +147,34 @@ impl Document { /// Set the design to a row's values (all or nothing). pub fn apply_configuration(&mut self, row: &str) -> Result<()> { + self.apply_configuration_with_trace(row).map(|_| ()) + } + + pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result> { let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?; if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() { return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into())); } let mut next = self.clone(); + let changes: Vec<(String, String)> = self + .configs + .columns + .iter() + .zip(&r.cells) + .filter_map(|(c, v)| match c { + Column::Param(name) => Some((name.clone(), v.clone())), + _ => None, + }) + .collect(); + next.change_params_atomic(&changes).map_err(|e| DocError::Invalid(format!("configuration `{row}`: {e}")))?; for (c, v) in self.configs.columns.iter().zip(&r.cells) { if v.len() > 4096 { return Err(DocError::Invalid("configuration value too long".into())); } match c { - Column::Param(n) => { - if next.config_current(c).as_deref() != Some(v.as_str()) { - next.change_param(n, v, None, None).map_err(|e| DocError::Invalid(format!("configuration `{row}`, parameter `{n}`: {e}")))?; - } - } + Column::Param(_) => {} Column::Suppress(id) => { - let on = match v.trim() { - "true" | "1" | "yes" => true, - "false" | "0" | "no" => false, - o => return Err(DocError::Invalid(format!("configuration `{row}`: suppression must be true or false, not `{o}`"))), - }; + let on = suppression_cell(v)?; let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?; f.suppressed = on; } @@ -140,8 +197,9 @@ impl Document { } } next.configs.active = Some(row.to_string()); + let trace = next.evaluate_configuration_rules()?; *self = next; - Ok(()) + Ok(trace) } /// The active row's values all hold in the design. @@ -150,9 +208,24 @@ impl Document { if r.cells.len() != self.configs.columns.len() { return false; } + if !self.configs.rules.is_empty() { + let mut expected = self.clone(); + if expected.apply_configuration(&r.name).is_err() { + return false; + } + return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column)); + } self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c { - Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()), - Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")), + Column::Suppress(id) => suppression_cell(v).is_ok_and(|on| self.feature(*id).is_some_and(|f| f.suppressed == on)), + Column::Param(name) => { + let kind = self + .features + .iter() + .find_map(|f| f.param_names.iter().position(|n| n == name).and_then(|k| f.kind.inputs().get(k).map(|input| input.2))); + let expected = kind.map_or_else(|| v.trim().to_string(), |kind| self.with_design_unit(v, kind)); + self.config_current(c) + .is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace()))) + } Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()), Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()), }) @@ -163,6 +236,53 @@ impl Document { mod tests { use super::*; + #[test] + fn rows_use_final_graph_and_missing_targets_never_reappear() { + let mut d = Document::new("A"); + d.set_param("a", "1 mm", None, None).unwrap(); + d.set_param("b", "a", None, None).unwrap(); + d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())]; + d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] }); + d.apply_configuration("Reordered").unwrap(); + assert!(d.configuration_matches()); + assert_eq!(d.param_values().0["a"].v, 2.0); + d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] }); + let before = d.clone(); + assert!(d.apply_configuration("Cycle").is_err()); + assert_eq!(d, before); + d.params.retain(|p| p.name != "a"); + let before = d.clone(); + assert!(d.apply_configuration("Reordered").is_err()); + assert_eq!(d, before); + } + + #[test] + fn bulk_changes_reject_new_errors_in_unchanged_dependents() { + let mut d = Document::new("A"); + d.set_param("length", "2 mm", None, None).unwrap(); + d.set_param("twice", "length * 2", None, None).unwrap(); + let before = d.clone(); + assert!(d.change_params_atomic(&[("length".into(), "1 / 0".into())]).is_err()); + assert_eq!(d, before); + assert!(d.change_params_atomic(&[("length".into(), "3 mm".into()), ("length".into(), "4 mm".into())]).is_err()); + assert_eq!(d, before); + } + + #[test] + fn unchanged_erroneous_configured_parameter_is_a_noop_but_changed_error_rejects() { + let mut d = Document::new("Existing broken design"); + d.set_param("width", "10 mm", None, None).unwrap(); + d.params[0].expr = "1/".into(); + d.configs.columns = vec![Column::Param("width".into())]; + d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] }); + d.apply_configuration("Default").unwrap(); + assert!(d.configuration_matches()); + d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] }); + let before = d.clone(); + assert!(d.apply_configuration("Different error").is_err()); + assert_eq!(d, before); + } + #[test] fn rows_switch_parameters() { let mut d = Document::new("A"); diff --git a/crates/doc/src/config_rules.rs b/crates/doc/src/config_rules.rs new file mode 100644 index 00000000..f186fbf4 --- /dev/null +++ b/crates/doc/src/config_rules.rs @@ -0,0 +1,646 @@ +//! Bounded typed configuration rules. The saved row remains the editable source; +//! ordered rules derive an effective design without overwriting its cells. +use super::Column; +use crate::{DocError, Document, Result, expr}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +pub const MAX_RULES: usize = 128; +const MAX_NODES: usize = 64; +const MAX_DEPTH: usize = 8; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigRule { + pub id: String, + pub name: String, + #[serde(default = "enabled")] + pub enabled: bool, + /// Unit context for feature-length expressions, fixed when authoring a rule. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub length_unit: Option, + pub condition: RuleCondition, + pub effect: RuleEffect, +} +fn enabled() -> bool { + true +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum RuleCondition { + Always, + All { conditions: Vec }, + Any { conditions: Vec }, + Not { condition: Box }, + Parameter { parameter: String, operator: RuleOperator, expression: String }, + Suppression { feature: u64, value: bool }, +} +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuleOperator { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum RuleEffect { + DriveParameter { parameter: String, expression: String }, + DriveSuppression { feature: u64, value: bool }, + ParameterRange { parameter: String, minimum: String, maximum: String }, + AllowParameterValues { parameter: String, expressions: Vec }, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RuleTrace { + pub id: String, + pub name: String, + pub matched: bool, + pub effect: RuleEffect, +} + +/// Check saved rule structure before recursive serde decoding. Absence is backward +/// compatible; malformed present data is refused. Runtime evaluation checks it again. +pub fn validate_rules_json(document: &Value) -> Result<()> { + let Some(rules) = document.get("configs").and_then(|configs| configs.get("rules")) else { return Ok(()) }; + let rules = rules.as_array().ok_or_else(|| invalid("rules must be an array"))?; + if rules.len() > MAX_RULES { + return Err(invalid("too many rules")); + } + for rule in rules { + let rule = rule.as_object().ok_or_else(|| invalid("rule must be an object"))?; + for (key, limit) in [("id", 64), ("name", 128)] { + let value = rule.get(key).and_then(Value::as_str).ok_or_else(|| invalid("rule id and name are required"))?; + if value.trim().is_empty() || value.chars().count() > limit { + return Err(invalid("rule id or name is empty or too long")); + } + } + if let Some(unit) = rule.get("length_unit") { + let unit = + unit.as_str().filter(|unit| matches!(*unit, "mm" | "cm" | "m" | "in" | "ft")).ok_or_else(|| invalid("invalid rule length unit"))?; + let _ = unit; + } + let mut pending = vec![(rule.get("condition").ok_or_else(|| invalid("condition required"))?, 1usize)]; + let mut count = 0; + while let Some((condition, depth)) = pending.pop() { + count += 1; + if depth > MAX_DEPTH || count > MAX_NODES { + return Err(invalid("condition nesting or node limit exceeded")); + } + let kind = condition.get("type").and_then(Value::as_str).ok_or_else(|| invalid("condition type required"))?; + match kind { + "all" | "any" => { + let children = condition.get("conditions").and_then(Value::as_array).ok_or_else(|| invalid("conditions must be an array"))?; + if children.is_empty() || children.len() > MAX_NODES { + return Err(invalid("condition group is empty or too large")); + } + pending.extend(children.iter().map(|child| (child, depth + 1))); + } + "not" => pending.push((condition.get("condition").ok_or_else(|| invalid("nested condition required"))?, depth + 1)), + "parameter" => { + check_parameter(condition.get("parameter"))?; + check_expression(condition.get("expression"))?; + } + "always" | "suppression" => {} + _ => return Err(invalid("unknown condition type")), + } + } + let effect = rule.get("effect").ok_or_else(|| invalid("effect required"))?; + if effect.get("type").and_then(Value::as_str) != Some("drive_suppression") { + check_parameter(effect.get("parameter"))?; + } + match effect.get("type").and_then(Value::as_str) { + Some("drive_parameter") => check_expression(effect.get("expression"))?, + Some("parameter_range") => { + check_expression(effect.get("minimum"))?; + check_expression(effect.get("maximum"))?; + } + Some("allow_parameter_values") => { + let expressions = effect.get("expressions").and_then(Value::as_array).ok_or_else(|| invalid("allowed values must be an array"))?; + if expressions.is_empty() || expressions.len() > 128 { + return Err(invalid("allowed values are empty or too many")); + } + for expression in expressions { + check_expression(Some(expression))?; + } + } + Some("drive_suppression") => {} + _ => return Err(invalid("unknown effect type")), + } + } + Ok(()) +} +fn check_expression(value: Option<&Value>) -> Result<()> { + let text = value.and_then(Value::as_str).ok_or_else(|| invalid("expression must be text"))?; + if text.trim().is_empty() || text.len() > 4096 { + return Err(invalid("expression is empty or too long")); + } + Ok(()) +} +fn check_parameter(value: Option<&Value>) -> Result<()> { + let name = value.and_then(Value::as_str).ok_or_else(|| invalid("parameter name required"))?; + if name.is_empty() || name.len() > 64 || Document::param_name_problem(name).is_some() { + return Err(invalid("invalid parameter name")); + } + Ok(()) +} +fn invalid(message: &str) -> DocError { + DocError::Invalid(format!("configuration rules: {message}")) +} + +fn operands( + doc: &Document, + values: &BTreeMap, + parameter: &str, + expression: &str, + length_unit: Option<&str>, +) -> Result<(f64, f64)> { + let lhs = values.get(parameter).copied().ok_or_else(|| invalid(&format!("parameter `{parameter}` is missing or invalid")))?; + let unit = doc + .param(parameter) + .map(|p| p.unit.as_str()) + .or_else(|| lhs.kind().map(|kind| if kind == expr::Kind::Length { length_unit.unwrap_or(doc.units.as_str()) } else { kind.default_unit() })) + .unwrap_or(""); + let defaults = expr::Defaults::for_unit(unit, expr::unit_info(&doc.units).map(|(_, scale)| scale).unwrap_or(1.0)); + let rhs = expr::eval_with_defaults( + expression, + &|name| values.get(name).copied().ok_or_else(|| invalid(&format!("unknown parameter `{name}`"))), + defaults, + )?; + let kind = lhs.kind().ok_or_else(|| invalid("comparison requires length, angle or unitless values"))?; + let right = rhs.to_kind_in(kind, defaults)?; + if !lhs.v.is_finite() || !right.is_finite() { + return Err(invalid("comparison requires finite values")); + } + Ok((lhs.v, right)) +} +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0) +} +fn validate_drive_graph(graph: &BTreeMap>, parameter: &str, expression: &str) -> Result<()> { + let mut pending = expr::references(expression); + let mut visited = BTreeSet::new(); + while let Some(name) = pending.pop() { + if name == parameter { + return Err(invalid(&format!("drive for `{parameter}` creates a parameter cycle"))); + } + if visited.insert(name.clone()) + && let Some(references) = graph.get(&name) + { + pending.extend(references.iter().cloned()); + } + } + Ok(()) +} +fn condition(doc: &Document, values: &BTreeMap, value: &RuleCondition, length_unit: Option<&str>) -> Result { + match value { + RuleCondition::Always => Ok(true), + RuleCondition::All { conditions } => { + for c in conditions { + if !condition(doc, values, c, length_unit)? { + return Ok(false); + } + } + Ok(true) + } + RuleCondition::Any { conditions } => { + for c in conditions { + if condition(doc, values, c, length_unit)? { + return Ok(true); + } + } + Ok(false) + } + RuleCondition::Not { condition: c } => Ok(!condition(doc, values, c, length_unit)?), + RuleCondition::Suppression { feature, value } => { + Ok(doc.feature(*feature).ok_or_else(|| invalid("condition feature is gone"))?.suppressed == *value) + } + RuleCondition::Parameter { parameter, operator, expression } => { + let (a, b) = operands(doc, values, parameter, expression, length_unit)?; + Ok(match operator { + RuleOperator::Eq => close(a, b), + RuleOperator::Ne => !close(a, b), + RuleOperator::Lt => a < b, + RuleOperator::Le => a <= b || close(a, b), + RuleOperator::Gt => a > b, + RuleOperator::Ge => a >= b || close(a, b), + }) + } + } +} + +impl Document { + /// Apply ordered drives, then enforce all matched restrictions on the final state. + /// Running on a scratch document makes a failing rule fully atomic for callers. + pub(super) fn evaluate_configuration_rules(&mut self) -> Result> { + validate_rules_json(&serde_json::json!({"configs":{"rules":self.configs.rules}}))?; + let mut ids = BTreeSet::new(); + let mut trace = Vec::new(); + let mut restrictions = Vec::new(); + let (mut values, _) = self.param_values(); + let parameter_names: BTreeSet = self.all_param_exprs().into_iter().map(|(name, _, _)| name).collect(); + let mut dependency_graph: BTreeMap> = + self.all_param_exprs().into_iter().map(|(name, expression, _)| (name, expr::references(&expression))).collect(); + for rule in self.configs.rules.clone() { + if !ids.insert(rule.id.clone()) { + return Err(invalid("duplicate rule id")); + } + if let Some(name) = rule.parameter_references().iter().find(|name| !parameter_names.contains(*name)) { + return Err(invalid(&format!("rule `{}` references missing parameter `{name}`", rule.name))); + } + // Targets must remain configured, including disabled rules, so dormant rules + // cannot silently become invalid and surprise a subsequent activation. + let target = match &rule.effect { + RuleEffect::DriveParameter { parameter, .. } + | RuleEffect::ParameterRange { parameter, .. } + | RuleEffect::AllowParameterValues { parameter, .. } => Column::Param(parameter.clone()), + RuleEffect::DriveSuppression { feature, .. } => Column::Suppress(*feature), + }; + if !self.configs.columns.contains(&target) { + return Err(invalid(&format!("rule `{}` target is not configured", rule.name))); + } + let matched = rule.enabled + && condition(self, &values, &rule.condition, rule.length_unit.as_deref()) + .map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?; + trace.push(RuleTrace { id: rule.id, name: rule.name.clone(), matched, effect: rule.effect.clone() }); + if !matched { + continue; + } + match rule.effect { + RuleEffect::DriveParameter { parameter, mut expression } => { + validate_drive_graph(&dependency_graph, ¶meter, &expression)?; + dependency_graph.insert(parameter.clone(), expr::references(&expression)); + if self.param(¶meter).is_none() + && rule.length_unit.as_deref().is_some_and(|unit| unit != self.units) + && values.get(¶meter).is_some_and(|value| value.kind() == Some(expr::Kind::Length)) + { + let (_, value) = operands(self, &values, ¶meter, &expression, rule.length_unit.as_deref())?; + expression = format!("{value} mm"); + } + self.change_params_atomic(&[(parameter, expression)]).map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?; + values = self.param_values().0; + } + RuleEffect::DriveSuppression { feature, value } => { + self.feature_mut(feature).ok_or_else(|| invalid("rule feature is gone"))?.suppressed = value + } + effect => restrictions.push((rule.name, effect, rule.length_unit)), + } + } + for (name, effect, length_unit) in restrictions { + let valid = match effect { + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + let (value, min) = operands(self, &values, ¶meter, &minimum, length_unit.as_deref())?; + let (_, max) = operands(self, &values, ¶meter, &maximum, length_unit.as_deref())?; + min <= max && (value >= min || close(value, min)) && (value <= max || close(value, max)) + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + let mut valid = false; + for expression in expressions { + let (a, b) = operands(self, &values, ¶meter, &expression, length_unit.as_deref())?; + valid |= close(a, b); + } + valid + } + _ => true, + }; + if !valid { + return Err(invalid(&format!("rule `{name}` restricts the final value"))); + } + } + Ok(trace) + } +} + +impl ConfigRule { + pub(super) fn preserve_length_units(&mut self, parameters: &BTreeSet, unit: &str) { + if self.length_unit.is_none() { + self.length_unit = Some(unit.to_string()); + } + fn annotate(parameter: &str, expression: &mut String, parameters: &BTreeSet, unit: &str) { + if parameters.contains(parameter) && expression.trim().parse::().is_ok() { + *expression = format!("{} {unit}", expression.trim()); + } + } + fn walk(condition: &mut RuleCondition, parameters: &BTreeSet, unit: &str) { + match condition { + RuleCondition::Parameter { parameter, expression, .. } => annotate(parameter, expression, parameters, unit), + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => { + for condition in conditions { + walk(condition, parameters, unit); + } + } + RuleCondition::Not { condition } => walk(condition, parameters, unit), + _ => {} + } + } + walk(&mut self.condition, parameters, unit); + match &mut self.effect { + RuleEffect::DriveParameter { parameter, expression } => annotate(parameter, expression, parameters, unit), + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + annotate(parameter, minimum, parameters, unit); + annotate(parameter, maximum, parameters, unit); + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + for expression in expressions { + annotate(parameter, expression, parameters, unit); + } + } + _ => {} + } + } + + /// Parameter references include targets and condition/effect expressions. + pub fn parameter_references(&self) -> Vec { + let mut copy = self.clone(); + let mut references = Vec::new(); + copy.visit_parameters(&mut |text, expression| { + if expression { + references.extend(expr::references(text)); + } else { + references.push(text.clone()); + } + }); + references + } + pub fn rename_parameter(&mut self, old: &str, new: &str) { + self.visit_parameters(&mut |text, expression| { + if expression { + *text = expr::rename_reference(text, old, new); + } else if text == old { + *text = new.to_string(); + } + }); + } + fn visit_parameters(&mut self, visit: &mut impl FnMut(&mut String, bool)) { + fn walk(c: &mut RuleCondition, visit: &mut impl FnMut(&mut String, bool)) { + match c { + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => { + for c in conditions { + walk(c, visit); + } + } + RuleCondition::Not { condition } => walk(condition, visit), + RuleCondition::Parameter { parameter, expression, .. } => { + visit(parameter, false); + visit(expression, true); + } + _ => {} + } + } + walk(&mut self.condition, visit); + match &mut self.effect { + RuleEffect::DriveParameter { parameter, expression } => { + visit(parameter, false); + visit(expression, true); + } + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + visit(parameter, false); + visit(minimum, true); + visit(maximum, true); + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + visit(parameter, false); + for e in expressions { + visit(e, true); + } + } + _ => {} + } + } + pub fn references_features(&self, ids: &[u64]) -> bool { + fn condition(c: &RuleCondition, ids: &[u64]) -> bool { + match c { + RuleCondition::Suppression { feature, .. } => ids.contains(feature), + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => conditions.iter().any(|c| condition(c, ids)), + RuleCondition::Not { condition: c } => condition(c, ids), + _ => false, + } + } + condition(&self.condition, ids) || matches!(&self.effect,RuleEffect::DriveSuppression {feature,..} if ids.contains(feature)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigRow; + fn design() -> Document { + let mut doc = Document::new("Rules"); + doc.set_param("width", "10 mm", None, None).unwrap(); + doc.set_param("depth", "5 mm", None, None).unwrap(); + doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())]; + doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] }); + doc + } + fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule { + ConfigRule { id: id.into(), name: id.into(), enabled: true, length_unit: None, condition, effect } + } + #[test] + fn ordered_rules_derive_values_without_overwriting_rows() { + let mut doc = design(); + doc.configs.rules = vec![ + rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "1 in".into() }), + rule( + "second", + RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Gt, expression: "20 mm".into() }, + RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() }, + ), + ]; + let source = doc.configs.rows.clone(); + let trace = doc.apply_configuration_with_trace("Default").unwrap(); + assert!(trace.iter().all(|entry| entry.matched)); + assert!((doc.param_values().0["depth"].v - 12.7).abs() < 1e-9); + assert_eq!(doc.configs.rows, source); + assert!(doc.configuration_matches()); + doc.configs.rules.swap(0, 1); + let trace = doc.apply_configuration_with_trace("Default").unwrap(); + assert!(!trace[0].matched); + assert_eq!(doc.param_values().0["depth"].v, 5.0); + } + #[test] + fn restrictions_intersect_and_fail_atomically_with_named_rule() { + let mut doc = design(); + doc.configs.rules = vec![ + rule( + "range", + RuleCondition::Always, + RuleEffect::ParameterRange { parameter: "width".into(), minimum: "5 mm".into(), maximum: "1 in".into() }, + ), + rule( + "choices", + RuleCondition::Always, + RuleEffect::AllowParameterValues { parameter: "width".into(), expressions: vec!["1 cm".into(), "20 mm".into()] }, + ), + ]; + doc.apply_configuration("Default").unwrap(); + doc.configs.rows[0].cells[0] = "15 mm".into(); + let before = doc.clone(); + assert!(doc.apply_configuration("Default").unwrap_err().to_string().contains("choices")); + assert_eq!(doc, before); + doc.configs.rules[0].effect = RuleEffect::ParameterRange { parameter: "width".into(), minimum: "1 deg".into(), maximum: "90 deg".into() }; + assert!(doc.apply_configuration("Default").is_err()); + } + #[test] + fn rule_references_follow_rename_and_prevent_delete() { + let mut doc = design(); + doc.configs.rules.push(rule( + "derive", + RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Ge, expression: "depth".into() }, + RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() }, + )); + assert!(doc.remove_param("width").is_err()); + doc.rename_param("width", "span").unwrap(); + let refs = doc.configs.rules[0].parameter_references(); + assert!(refs.contains(&"span".into())); + assert!(!refs.contains(&"width".into())); + doc.apply_configuration("Default").unwrap(); + assert_eq!(doc.param_values().0["depth"].v, 5.0); + } + #[test] + fn angle_and_unitless_comparisons_keep_their_dimensions() { + let mut doc = design(); + doc.set_param("angle", "45 deg", Some("deg"), None).unwrap(); + doc.set_param("count", "1", Some(""), None).unwrap(); + doc.configs.columns.push(Column::Param("count".into())); + doc.configs.rows[0].cells.push("1".into()); + doc.configs.rules.push(rule( + "angle-drive", + RuleCondition::Parameter { parameter: "angle".into(), operator: RuleOperator::Gt, expression: "1".into() }, + RuleEffect::DriveParameter { parameter: "count".into(), expression: "2".into() }, + )); + let trace = doc.apply_configuration_with_trace("Default").unwrap(); + assert!(trace[0].matched); + assert_eq!(doc.param_values().0["count"].v, 2.0); + doc.configs.rules[0].condition = + RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "1 deg".into() }; + let before = doc.clone(); + assert!(doc.apply_configuration("Default").is_err()); + assert_eq!(doc, before); + } + + #[test] + fn saved_rule_budgets_are_checked_before_deserialization() { + let valid = serde_json::to_value(rule( + "r", + RuleCondition::Always, + RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() }, + )) + .unwrap(); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[valid.clone()]}})).is_ok()); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":null}})).is_err()); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();129]}})).is_err()); + let mut condition = serde_json::json!({"type":"always"}); + for _ in 0..8 { + condition = serde_json::json!({"type":"not","condition":condition}); + } + let mut deep = valid.clone(); + deep["condition"] = condition; + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[deep]}})).is_err()); + let mut wide = valid.clone(); + wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]}); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[wide]}})).is_err()); + let mut long = valid.clone(); + long["effect"]["expression"] = serde_json::json!("a".repeat(4097)); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[long]}})).is_err()); + let mut malformed = valid; + malformed["condition"] = serde_json::json!({"type":"any","conditions":{}}); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[malformed]}})).is_err()); + } + + #[test] + fn compound_conditions_short_circuit_guarded_numeric_expressions() { + let mut doc = design(); + doc.set_param("count", "0", Some(""), None).unwrap(); + let guard = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "0".into() }; + let invalid_when_zero = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "1 / count".into() }; + doc.configs.rules = vec![rule( + "guarded", + RuleCondition::All { conditions: vec![guard, invalid_when_zero.clone()] }, + RuleEffect::DriveParameter { parameter: "width".into(), expression: "20 mm".into() }, + )]; + assert!(!doc.apply_configuration_with_trace("Default").unwrap()[0].matched); + assert_eq!(doc.param_values().0["width"].v, 10.0); + doc.configs.rules[0].condition = RuleCondition::Any { + conditions: vec![ + RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "0".into() }, + invalid_when_zero, + ], + }; + assert!(doc.apply_configuration_with_trace("Default").unwrap()[0].matched); + assert_eq!(doc.param_values().0["width"].v, 20.0); + } + + #[test] + fn saved_documents_reject_malformed_deep_and_oversized_rules_before_typed_decode() { + let valid = serde_json::to_value(rule( + "r", + RuleCondition::Always, + RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() }, + )) + .unwrap(); + let mut deep = valid.clone(); + let mut condition = serde_json::json!({"type":"always"}); + for _ in 0..8 { + condition = serde_json::json!({"type":"not","condition":condition}); + } + deep["condition"] = condition; + let mut wide = valid.clone(); + wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]}); + let mut invalid_context = valid.clone(); + invalid_context["length_unit"] = serde_json::json!("mile".repeat(1000)); + for invalid in [ + Value::Null, + serde_json::json!([deep]), + serde_json::json!([wide]), + serde_json::json!(vec![valid; 129]), + serde_json::json!([invalid_context]), + ] { + let mut saved = serde_json::to_value(design()).unwrap(); + saved["configs"]["rules"] = invalid; + let error = crate::format::read(&saved.to_string()).unwrap_err().to_string(); + assert!(error.contains("configuration rules"), "saved boundary must report bounded rule validation: {error}"); + } + } + + #[test] + fn cross_unit_effective_literals_keep_declared_drive_cycle_checks() { + let mut doc = Document::new("Cross-unit drive graph"); + doc.add_feature( + crate::FeatureKind::Box { + corner: ["0".into(), "0".into(), "0".into()], + length: "10 mm".into(), + width: "5 mm".into(), + height: "10 mm".into(), + operation: crate::Operation::NewBody, + }, + None, + ) + .unwrap(); + let width = doc.features[0].param_names[0].clone(); + let depth = doc.features[0].param_names[1].clone(); + doc.rename_param(&width, "width").unwrap(); + doc.rename_param(&depth, "depth").unwrap(); + doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())]; + doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] }); + doc.units = "in".into(); + let mut first = + rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() }); + first.length_unit = Some("mm".into()); + let mut second = + rule("second", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width + 1".into() }); + second.length_unit = Some("mm".into()); + doc.configs.rules = vec![first]; + doc.apply_configuration("Default").unwrap(); + assert_eq!(doc.config_current(&Column::Param("width".into())).as_deref(), Some("6 mm")); + doc.configs.rules.push(second); + let before = doc.clone(); + assert!(doc.apply_configuration("Default").is_err()); + assert_eq!(doc, before); + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 0fce5008..f9085052 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1586,7 +1586,7 @@ impl Document { /// else its component's, else its material's look, else the colour it was imported with. /// (Faces may have their own: `face_looks`.) pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> { - self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color)) + self.model_body_look(b).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color)) } /// The model with each body carrying the colour, opacity and face colours it is shown with @@ -1595,7 +1595,7 @@ impl Document { let mut st = state.clone(); for b in &mut st.bodies { let c = self.body_color(b); - let look = self.body_look(&b.name, b.feature); + let look = self.model_body_look(b); let faces: Vec = self .face_colors(b) .into_iter() @@ -1739,9 +1739,22 @@ impl Document { let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?; let mut gone = vec![id]; gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id)); + let gone_params: Vec = self.features.iter().filter(|f| gone.contains(&f.id)).flat_map(|f| f.param_names.clone()).collect(); + self.configs + .rules + .retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name))); self.features.retain(|f| !gone.contains(&f.id)); // Configuration columns of deleted features go with them. - let keep: Vec = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect(); + let keep: Vec = self + .configs + .columns + .iter() + .map(|c| match c { + crate::config::Column::Suppress(id) => !gone.contains(id), + crate::config::Column::Param(name) => !gone_params.contains(name), + _ => true, + }) + .collect(); if keep.contains(&false) { let mut k = keep.iter(); self.configs.columns.retain(|_| k.next().copied().unwrap_or(true)); @@ -1768,6 +1781,19 @@ impl Document { fn referenced_names(&self) -> Vec { let mut used: Vec = Vec::new(); + for rule in &self.configs.rules { + used.extend(rule.parameter_references()); + } + for (index, column) in self.configs.columns.iter().enumerate() { + if let crate::config::Column::Param(name) = column { + used.push(name.clone()); + for row in &self.configs.rows { + if let Some(value) = row.cells.get(index) { + used.extend(expr::references(value)); + } + } + } + } for f in &self.features { for e in f.kind.expressions() { used.extend(expr::references(e)); @@ -1960,6 +1986,9 @@ impl Document { c.kind.set_value(v); } } + for c in &sk.constraints { + sk.validate(&c.kind)?; + } Ok(()) } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 5dba61bb..bfd25e24 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -16,6 +16,10 @@ use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operati #[derive(Clone, Debug)] pub struct ModelBody { pub name: String, + /// Definition identity for a computed placement; never parsed from its display name. + pub source_name: Option, + /// Definition geometry coordinates to this computed body's frame. + pub source_transform: crate::Mat, pub body: Body, /// Feature that created it. pub feature: u64, @@ -35,7 +39,21 @@ pub struct ModelBody { impl ModelBody { pub fn new(name: String, body: Body, feature: u64) -> Self { - ModelBody { name, body, feature, mesh: Arc::new(OnceLock::new()), key: 0, names: Default::default(), component: 0, imported: None } + ModelBody { + name, + source_name: None, + source_transform: crate::IDENTITY, + body, + feature, + mesh: Arc::new(OnceLock::new()), + key: 0, + names: Default::default(), + component: 0, + imported: None, + } + } + pub fn definition_name(&self) -> &str { + self.source_name.as_deref().unwrap_or(&self.name) } /// Display mesh (chord tolerance 1/1000 of the body size), computed once. pub fn mesh(&self) -> Arc { @@ -807,6 +825,8 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation } Operation::Cut | Operation::Intersect => { let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect }; + let affected: std::collections::BTreeSet = + target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect(); let mut remove = Vec::new(); for &ti in &target_idx { let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.body.clone())) else { continue }; @@ -834,6 +854,10 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation // A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"). let mut split: Vec = Vec::new(); for mb in std::mem::take(&mut state.bodies) { + if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) { + split.push(mb); + continue; + } let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]); if pieces.len() < 2 { split.push(mb); @@ -2370,11 +2394,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { let handled = more::sheet_cut(f, st, &tools, targets)?; if !handled.is_empty() { + let effective = if targets.is_empty() { &f.participants } else { targets }; + let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*"); let rest: Vec = st .bodies .iter() + .filter(|b| { + !handled.contains(&b.name) + && (all + || if effective.is_empty() { + b.component == f.component || b.feature == f.id + } else { + effective.contains(&b.name) + }) + }) .map(|b| b.name.clone()) - .filter(|n| !handled.contains(n) && (targets.is_empty() || targets.contains(n))) .collect(); if rest.is_empty() { return Ok(()); @@ -2873,14 +2907,19 @@ fn unique_body_name(st: &ModelState, base: &str) -> String { /// face finds the face where it is shown and projects its edges there. The model itself when /// every placement agrees (the usual case). pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> { + state_in_frame_at(doc, st, comp, doc.component_transform(comp)) +} + +/// As `state_in_frame`, using the exact active placement for its shared source geometry. +pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, frame: Mat) -> std::borrow::Cow<'a, ModelState> { use std::borrow::Cow; if doc.occurrences.is_empty() && doc.body_offsets.is_empty() { return Cow::Borrowed(st); } - let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) }; + let Some(inv) = crate::mat_inverse(&frame) else { return Cow::Borrowed(st) }; let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0); let rel = |c: u64, body: Option<&str>| { - let m = crate::mat_mul(&inv, &doc.component_transform(c)); + let m = crate::mat_mul(&inv, &if c == comp { frame } else { doc.component_transform(c) }); match body.and_then(|b| doc.body_offsets.get(b)) { Some(o) => crate::mat_mul(&m, o), None => m, @@ -2900,6 +2939,9 @@ pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std: let names = crate::naming::face_names(b); let mut nb = ModelBody::new(b.name.clone(), t, b.feature); nb.component = b.component; + nb.source_name = b.source_name.clone(); + nb.source_transform = crate::mat_mul(m, &b.source_transform); + nb.imported = b.imported.clone(); nb.names = crate::naming::NameCell::known(names); *b = nb; } @@ -2938,17 +2980,18 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { let mut out = ModelState { body_counter: st.body_counter, ..Default::default() }; for (comp, path, m) in doc.placements() { let primary = path == first_path(comp); - let label = || -> String { - path.iter().filter_map(|id| doc.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::>().join("/") - }; let ident = crate::is_identity(&m); for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) { - let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) }; + let name = doc.world_body_name(st, &b.name, comp, &path); // A body moved in from another component keeps its place (its offset). let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m); let body = if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) }; let mut placed = ModelBody::new(name, body, b.feature); + placed.source_name = Some(b.definition_name().to_string()); + placed.source_transform = crate::mat_mul(&bm, &b.source_transform); + placed.component = comp; + placed.imported = b.imported.clone(); // Placement preserves topology: face ownership remains that of the // source model, including modifiers on component instances. placed.names = crate::naming::NameCell::known(crate::naming::face_names(b)); diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 84b30e53..4c7a0ec8 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -497,17 +497,22 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target } } let mut handled = Vec::new(); + let mut overlap_work = crate::sheet::FlatWork::new(); + let targets = if targets.is_empty() { &f.participants } else { targets }; + let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); for i in 0..st.sheets.len() { let Some(sh) = st.sheets.get(i).cloned() else { continue }; - if !targets.is_empty() && !targets.contains(&sh.body) { + let eligible = all + || if targets.is_empty() { + st.bodies.iter().any(|b| b.name == sh.body && (b.component == f.component || b.feature == f.id)) + } else { + targets.contains(&sh.body) + }; + if !eligible { continue; } let candidates: Vec<(Option, Region2)> = if sh.flat { - if sh.frame.normal().cross(n).len() > 1e-6 { - Vec::new() - } else { - vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })] - } + if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|region| (None, region)).collect() } } else { sh.parallel_panels(&ss.plane) }; @@ -520,11 +525,9 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 { continue; } - let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::>() } else { panel.outer.polyline(1e-3) }; for r in ®ions { - let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue }; - let fp = flat.outer.polyline(1e-3); - let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p)); + let flat = sh.region_to_flat(pi, &ss.plane, r)?; + let overlaps = crate::sheet::flat_regions_overlap(&panel, &flat, &mut overlap_work)?; if overlaps && !added.iter().any(|x: &Region2| x == &flat) { added.push(flat); } @@ -542,18 +545,6 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target Ok(handled) } -fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool { - let mut inside = false; - let m = poly.len(); - for i in 0..m { - let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue }; - if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { - inside = !inside; - } - } - inside -} - /// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance /// `level` from `at` along `n`: the edges used by one of them only, chained. fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec> { diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index cf3cea21..2ee77cfe 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -23,13 +23,15 @@ //! - 7: configuration tables can include body material and appearance columns. Version 6 //! rows need no transformation; the version stamp lets older readers request an update //! instead of encountering an unknown column variant. +//! - 8: ordered configuration rules and persistent signed line-angle, arc-sweep and slot +//! dimensions. Version 7 geometry and table values retain their meaning without changes. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 7; +pub const FORMAT: u32 = 8; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -42,7 +44,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 6] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(())]; +const JSON_STEPS: [JsonStep; 7] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(())]; /// Old XZ references retain their exact frame, including nested offset and angled planes. /// New origin references use +Y; freezing the old frame avoids changing sketch coordinates, @@ -133,6 +135,7 @@ pub fn read(s: &str) -> Result { ))); } check_numbers(&v, 0)?; + crate::config::validate_rules_json(&v)?; for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) { step(&mut v)?; } @@ -242,7 +245,7 @@ mod tests { let upgraded = read(&legacy.to_string()).unwrap(); assert_eq!(upgraded, d, "the identity migration preserves the complete design"); assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table); - assert_eq!(upgraded.format, "solvecraft/7"); + assert_eq!(upgraded.format, format_string(FORMAT)); assert!(upgraded.configuration_matches()); legacy.as_object_mut().unwrap().remove("configs"); assert!(read(&legacy.to_string()).unwrap().configs.is_empty()); @@ -251,7 +254,7 @@ mod tests { } #[test] - fn version_seven_styles_roundtrip_and_newer_versions_are_refused_before_columns() { + fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() { use crate::config::{Column, ConfigRow}; let mut d = Document::new("configured styles"); d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())]; @@ -261,17 +264,72 @@ mod tests { }]; d.apply_configuration("Blue steel").unwrap(); let mut value: Value = serde_json::from_str(&d.to_json()).unwrap(); - assert_eq!(value["format"], "solvecraft/7"); + value["format"] = Value::from("solvecraft/7"); + let original_table = value["configs"].clone(); let roundtrip = read(&value.to_string()).unwrap(); assert_eq!(roundtrip, d); + assert_eq!(serde_json::to_value(&roundtrip.configs).unwrap(), original_table); + assert_eq!(roundtrip.format, "solvecraft/8"); assert!(roundtrip.configuration_matches()); value["format"] = Value::from(format_string(FORMAT + 1)); value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]); + value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true}); let error = read(&value.to_string()).unwrap_err().to_string(); assert!(error.contains("newer SolveCraft") && error.contains("update SolveCraft"), "{error}"); assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants"); } + #[test] + fn version_seven_origin_sketch_keeps_geometry_dimension_ids_and_expressions() { + use solvecraft_geom::Vec2; + use solvecraft_sketch::{ConstraintKind, Sketch}; + let mut d = Document::new("legacy origin dimensions"); + d.set_param("reach", "25 mm", Some("mm"), None).unwrap(); + let dim = d.new_model_param("reach", "mm"); + let mut sketch = Sketch::new(); + let end = sketch.add_point(Vec2::new(25.0, 0.0), Some("tip")).unwrap(); + let line = sketch.add_line_pts(0, end, Some("edge")).unwrap(); + sketch.add_constraint(ConstraintKind::Horizontal { l: line }, None).unwrap(); + sketch.add_constraint(ConstraintKind::Length { l: line, value: 25.0 }, Some(dim)).unwrap(); + d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Dimensioned")).unwrap(); + let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap(); + legacy["format"] = Value::from("solvecraft/7"); + let features = legacy["features"].clone(); + let params = legacy["params"].clone(); + let upgraded = read(&legacy.to_string()).unwrap(); + assert_eq!(upgraded, d); + let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap(); + assert_eq!(upgraded_json["features"], features); + assert_eq!(upgraded_json["params"], params); + assert_eq!(read(&upgraded.to_json()).unwrap(), upgraded); + } + + #[test] + fn version_eight_persistent_angle_sweep_and_slot_dimensions_roundtrip() { + use solvecraft_geom::Vec2; + use solvecraft_sketch::{ConstraintKind, Sketch}; + let mut d = Document::new("persistent typed dimensions"); + let heading = d.new_model_param("45 deg", "deg"); + let sweep = d.new_model_param("180 deg", "deg"); + let overall = d.new_model_param("10 mm", "mm"); + let mut sketch = Sketch::new(); + let end = sketch.add_point(Vec2::new(2.0, 2.0), None).unwrap(); + let line = sketch.add_line_pts(0, end, None).unwrap(); + let a = sketch.add_arc(Vec2::ZERO, Vec2::new(0.0, 2.0), Vec2::new(0.0, -2.0), [None; 3], None).unwrap(); + let b = sketch.add_arc(Vec2::new(6.0, 0.0), Vec2::new(6.0, -2.0), Vec2::new(6.0, 2.0), [None; 3], None).unwrap(); + sketch.add_constraint(ConstraintKind::LineAngle { l: line, value: std::f64::consts::FRAC_PI_4 }, Some(heading)).unwrap(); + sketch.add_constraint(ConstraintKind::ArcSweep { c: a, value: std::f64::consts::PI }, Some(sweep)).unwrap(); + sketch.add_constraint(ConstraintKind::SlotOverallLength { a, b, value: 10.0 }, Some(overall)).unwrap(); + d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let mut saved: Value = serde_json::from_str(&d.to_json()).unwrap(); + assert_eq!(saved["format"], "solvecraft/8"); + assert_eq!(read(&saved.to_string()).unwrap(), d); + saved["format"] = Value::from(format_string(FORMAT + 1)); + saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension"); + let error = read(&saved.to_string()).unwrap_err().to_string(); + assert!(error.contains("newer SolveCraft") && !error.contains("unknown variant"), "{error}"); + } + #[test] fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() { let mut d = Document::new("input repair"); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f4b6cfc9..be6802b2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden pub use document::*; pub use eval::{ EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices, - state_in_frame, world_state, + state_in_frame, state_in_frame_at, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index e2b3d795..e7ce630d 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -502,6 +502,53 @@ impl Document { Ok(()) } + /// Change existing expressions against their final dependency graph, independent of + /// column order. Unlike `change_param`, this never creates a missing parameter. + pub fn change_params_atomic(&mut self, changes: &[(String, String)]) -> Result<()> { + if changes.len() > crate::config::MAX_COLUMNS { + return Err(DocError::Invalid("too many parameter changes".into())); + } + let mut names = BTreeSet::new(); + let mut changed = BTreeSet::new(); + let (_, before_errors) = self.param_values(); + let mut next = self.clone(); + for (name, expression) in changes { + if !names.insert(name.clone()) { + return Err(DocError::Invalid(format!("duplicate parameter `{name}`"))); + } + if expression.len() > 4096 || expression.trim().is_empty() { + return Err(DocError::Expr("expression is empty or too long".into())); + } + if let Some(p) = next.params.iter_mut().find(|p| p.name == *name) { + if p.expr != expression.trim() { + changed.insert(name.clone()); + } + p.expr = expression.trim().to_string(); + continue; + } + let hit = next.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k))); + let Some((i, k)) = hit else { return Err(DocError::Unknown(format!("parameter `{name}`"))) }; + let kind = next.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless); + let normalized = next.with_design_unit(expression, kind); + let Some(feature) = next.features.get_mut(i) else { return Err(DocError::Unknown(format!("parameter `{name}`"))) }; + let Some((_, target, _)) = feature.kind.inputs_mut().into_iter().nth(k) else { + return Err(DocError::Unknown(format!("parameter `{name}`"))); + }; + if *target != normalized { + changed.insert(name.clone()); + } + *target = normalized; + } + let (_, errors) = next.param_values(); + for (name, error) in errors { + if changed.contains(&name) || !before_errors.contains_key(&name) { + return Err(DocError::Expr(format!("parameter `{name}`: {error}"))); + } + } + *self = next; + Ok(()) + } + /// The parameters table (user, sketch dimensions, feature inputs). pub fn param_rows(&self) -> Vec { let (vals, errs) = self.param_values(); @@ -639,6 +686,8 @@ pub enum ParamUser { Feature { feature: u64, name: String, input: String }, /// A sketch dimension (`constraint` is its id). Dimension { feature: u64, name: String, constraint: String }, + /// A configured column or an expression saved in a configuration row. + Configuration { row: Option, column: String }, } impl ParamUser { @@ -647,6 +696,10 @@ impl ParamUser { ParamUser::Parameter { name } => format!("parameter {name}"), ParamUser::Feature { name, input, .. } => format!("{name} ({input})"), ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"), + ParamUser::Configuration { row, column } => match row { + Some(row) => format!("configuration `{row}` ({column})"), + None => format!("configuration column {column}"), + }, } } } @@ -716,6 +769,24 @@ impl Document { /// expressions refer to it, and the sketch dimensions it drives. pub fn param_users(&self, name: &str) -> Vec { let mut out = Vec::new(); + for rule in &self.configs.rules { + if rule.parameter_references().iter().any(|reference| reference == name) { + out.push(ParamUser::Configuration { row: None, column: format!("rule `{}`", rule.name) }); + } + } + for (index, column) in self.configs.columns.iter().enumerate() { + if let crate::config::Column::Param(target) = column { + let key = self.config_key(column); + if target == name { + out.push(ParamUser::Configuration { row: None, column: key.clone() }); + } + for row in &self.configs.rows { + if row.cells.get(index).is_some_and(|value| expr::references(value).iter().any(|reference| reference == name)) { + out.push(ParamUser::Configuration { row: Some(row.name.clone()), column: key.clone() }); + } + } + } + } for p in &self.params { if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) { out.push(ParamUser::Parameter { name: p.name.clone() }); @@ -828,6 +899,9 @@ impl Document { } } // Configuration cells refer to parameters too. + for rule in &mut self.configs.rules { + rule.rename_parameter(old, new); + } for r in &mut self.configs.rows { for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) { if matches!(column, crate::config::Column::Param(_)) { diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 6bee00c8..1d109626 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -16,6 +16,118 @@ use solvecraft_kernel::{self as kernel, Body, EdgeSpec, FaceSpec, SurfSpec}; use crate::expr::{self, Kind, Value}; use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector, mat_inverse, mat_mul}; +// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces. +// These are work limits, never geometry truncation: a larger input returns a useful error. +const MAX_FLAT_POINTS: usize = 16_384; +const MAX_FLAT_PIECES: usize = 4096; +const MAX_FLAT_WORK: usize = 500_000_000; + +fn flat_budget_error() -> DocError { + DocError::Invalid("sheet metal flat geometry exceeds the bounded processing budget".into()) +} + +pub(crate) struct FlatWork(usize); + +impl FlatWork { + pub(crate) fn new() -> Self { + Self(MAX_FLAT_WORK) + } + fn charge(&mut self, work: usize) -> Result<()> { + self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?; + Ok(()) + } +} + +fn flat_polyline(lp: &Loop2) -> Result> { + if lp.segs.len() > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + let mut points = Vec::new(); + for seg in &lp.segs { + let p = seg.polyline(1e-3); + let add = p.len().saturating_sub(1); + if points.len().saturating_add(add) > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + points.extend(p.iter().take(add)); + } + Ok(points) +} + +fn flat_points(polys: &[Vec]) -> Result { + let n = polys.iter().try_fold(0usize, |n, p| n.checked_add(p.len()).ok_or_else(flat_budget_error))?; + if n > MAX_FLAT_POINTS || polys.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + Ok(n) +} + +fn planar_region(region: &Region2) -> Result { + if region.holes.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + region_complexity(std::slice::from_ref(region))?; + let convert = |lp: &Loop2| -> Result { + if lp.segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) { + flat_polyline(lp)?; + Ok(lp.clone()) + } else { + // Keep free-form profiles through controlled 0.001 mm chord sampling. + Ok(Loop2::polygon(&flat_polyline(lp)?)) + } + }; + Ok(Region2 { + outer: convert(®ion.outer)?.ccw(), + holes: region.holes.iter().map(|h| convert(h).map(|l| l.ccw().reversed())).collect::>()?, + }) +} + +fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> { + if regions.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + let (mut edges, mut points) = (0usize, 0usize); + for lp in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) { + edges = edges.saturating_add(lp.segs.len()); + if edges > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + points = points.saturating_add(flat_polyline(lp)?.len()); + if points > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + } + Ok((edges, points)) +} + +fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result> { + let (ae, ap) = region_complexity(a)?; + let (be, bp) = region_complexity(b)?; + let edges = ae.saturating_add(be); + work.charge(edges.saturating_mul(edges).saturating_add(edges.saturating_mul(ap.saturating_add(bp))).saturating_mul(8))?; + let regions = kernel::planar_regions_boolean(a, b, op, 1e-7)?; + region_complexity(®ions)?; + Ok(regions) +} + +// Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not +// repeatedly arrange a growing accumulated boundary against just one small region. +fn material_union(regions: Vec, work: &mut FlatWork) -> Result> { + region_complexity(®ions)?; + let mut groups: Vec> = regions.into_iter().map(|r| vec![r]).collect(); + while groups.len() > 1 { + let mut next = Vec::new(); + let mut iter = groups.into_iter(); + while let Some(a) = iter.next() { + match iter.next() { + Some(b) => next.push(material_boolean(&a, &b, kernel::BoolOp::Union, work)?), + None => next.push(a), + } + } + groups = next; + } + Ok(groups.pop().unwrap_or_default()) +} /// A sheet metal rule. Values are expressions; `Thickness` inside them is the rule's thickness. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SheetRule { @@ -424,65 +536,70 @@ impl SheetBody { } } - /// The pieces of the sheet as cells: base and flange panels and bend zones, each less the - /// cut-outs over it (cut-outs inside a panel stay exact holes; ones crossing a piece's edge - /// must be convex polygons). - fn cells(&self) -> Result> { - let mut pieces: Vec<(Piece, Vec)> = - self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), dedup(&r.outer.ccw().polyline(1e-3)))).collect(); - for (i, f) in self.flanges.iter().enumerate() { - pieces.push((Piece::Zone(i), dedup(&self.zone(f).outer.ccw().polyline(1e-3)))); + /// The pieces of the sheet as cells: base and flange panels and bend zones, each less + /// the combined cut-out material. Planar clipping retains holes and disconnected islands. + fn cells_with_work(&self, work: &mut FlatWork) -> Result> { + if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); } + let raw = self + .flanges + .iter() + .fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len))) + .saturating_add(self.holes.iter().fold(0usize, |n, h| n.saturating_add(h.outer.segs.len()))); + if raw > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + let mut pieces: Vec<(Piece, Region2)> = self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), r)).collect(); + for (i, f) in self.flanges.iter().enumerate() { + pieces.push((Piece::Zone(i), self.zone(f))); + } + let mut cuts = Vec::new(); + let mut cut_points = 0usize; + for hole in &self.holes { + let region = planar_region(hole)?; + cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1); + if cut_points > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + cuts.push(region); + } + let cuts = material_union(cuts, work)?; let mut out = Vec::new(); - for (piece, poly) in pieces { - let mut inner: Vec> = Vec::new(); - let mut crossing: Vec> = Vec::new(); - for h in &self.holes { - let hp = dedup(&h.outer.ccw().polyline(1e-3)); - if matches!(piece, Piece::Panel(_)) && hp.iter().all(|p| point_in(&poly, *p)) { - inner.push(h.outer.ccw().reversed().segs.clone()); - } else if hp.iter().any(|p| point_in(&poly, *p)) || poly.iter().any(|p| point_in(&hp, *p)) || polys_cross(&poly, &hp) { - if !is_convex(&hp) { - return Err(DocError::Invalid( - "not supported yet: cut-outs over a panel's edge or a bend that are not convex polygons".into(), - )); - } - crossing.push(hp); + let mut points = 0usize; + for (piece, region) in pieces { + let panel = planar_region(®ion)?; + let material = if cuts.is_empty() { vec![panel] } else { material_boolean(&[panel], &cuts, kernel::BoolOp::Cut, work)? }; + for region in material { + points = points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1); + if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES { + return Err(flat_budget_error()); } - } - if crossing.is_empty() { - let outer = Loop2::polygon(&poly).segs; - out.push(Cell { - piece, - outer: if matches!(piece, Piece::Panel(None)) { self.base.outer.ccw().segs.clone() } else { outer }, - holes: inner, - }); - continue; - } - let mut parts = vec![poly]; - for h in &crossing { - parts = parts.iter().flat_map(|p| convex_diff(p, h)).collect(); - } - let loops = merge_loops(&parts); - let (outers, holes): (Vec>, 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 +616,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 +732,78 @@ 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)?; + Ok(kernel::sew(&self.faces(&cells, 0.0, self.t, &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())) + Ok(kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?) } + /// 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 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); + } + let cuts = material_union(cuts, &mut work)?; + let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? }; + if regions.is_empty() { + return Err(DocError::Invalid("empty flat pattern".into())); + } + Ok(regions) + } /// 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))); - } + if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES { + return Err(flat_budget_error()); } - let loops = merge_loops(&polys); + 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 +838,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 +856,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 +1282,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 +1307,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). @@ -1178,7 +1349,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 +1381,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 +1425,7 @@ fn merge_loops(polys: &[Vec]) -> Vec> { loops.push(clean); } } - loops + Ok(loops) } fn signed_area(p: &[Vec2]) -> f64 { @@ -1256,27 +1433,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 +1447,24 @@ 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, flat: false, gap: rv.gap, base_curves: Vec::new(), @@ -1510,4 +1665,119 @@ 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")); + } } diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 3f906def..008dc4ac 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -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.body_component(&b.name, b.feature) == 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 })) } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 193c896f..f7455944 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -8,21 +8,26 @@ 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"] mod properties; +#[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() @@ -35,13 +40,13 @@ pub static COMMANDS: &[CommandSpec] = &[ 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: copied with its own features into a new, independent component; occurrence?, path?: selected source placement; translate?: [x,y,z]"), 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 { @@ -66,12 +71,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) +} + +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); @@ -102,7 +156,18 @@ fn from_bodies(s: &mut Session, p: &Value) -> Result { 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 })) } @@ -231,16 +296,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})) } @@ -252,8 +331,27 @@ 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)?; + 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 parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent); + let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), 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(); @@ -276,10 +374,8 @@ fn paste_new(s: &mut Session, p: &Value) -> Result { slot.component = new_id; } } - 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); + o.transform = transform; } Ok(json!({"component": new_id, "features": new_ids})) } @@ -316,6 +412,34 @@ fn remap(v: &mut Value, ids: &std::collections::HashMap, names: &std:: } } +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`")); + } + } + 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 { let cmd = "occurrence.move"; let id = occurrence(s, p, cmd)?; @@ -323,19 +447,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) { @@ -351,6 +512,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); @@ -370,9 +534,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 })) } @@ -383,7 +562,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_properties.rs b/crates/engine/src/cmd/component_properties.rs index bef6410b..c642e85c 100644 --- a/crates/engine/src/cmd/component_properties.rs +++ b/crates/engine/src/cmd/component_properties.rs @@ -64,18 +64,11 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result { let cmd = "occurrence.properties"; let id = super::occurrence(s, p, cmd)?; let doc = placed_doc(s); - let paths: Vec> = placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect(); - let path = if let Some(value) = p.get("path") { - let path = value - .as_array() - .filter(|a| !a.is_empty() && a.len() <= 64) - .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) - .ok_or_else(|| bad(cmd, "`path` must list 1…64 occurrence ids"))?; - if !paths.contains(&path) { - return Err(bad(cmd, "`path` does not identify a placement of this occurrence")); - } - path + 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")), @@ -97,9 +90,27 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, (Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)); let (mut faces, mut edges, mut vertices) = (0, 0, 0); let mut placement_count = 0; - for (component, path, transform) in - placements(doc, cmd)?.into_iter().filter(|(_, path, _)| if descendants { path.starts_with(selected) } else { path == selected }) - { + 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.body_component(&b.name, b.feature) == component) { let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform); diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index 334e2bf8..993f1b9b 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -22,6 +22,9 @@ pub static COMMANDS: &[CommandSpec] = &[ "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 { @@ -99,7 +102,7 @@ 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()})) + Ok(json!({"columns": keys, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches()})) } fn column(s: &mut Session, p: &Value) -> Result { @@ -117,6 +120,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) } @@ -126,6 +132,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()) { @@ -190,6 +199,7 @@ fn row(s: &mut Session, p: &Value) -> Result { 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) { @@ -209,17 +219,104 @@ 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("; ")))); + } + 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); + 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) } @@ -240,6 +337,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(); 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 ddf62105..d6a975a4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -238,6 +238,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) { diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 342a29e7..dad2533e 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); } @@ -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 }) @@ -495,7 +496,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/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs new file mode 100644 index 00000000..7a6f0905 --- /dev/null +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -0,0 +1,421 @@ +// 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.occurrence_placement(&[other]).unwrap().world[3][0], 0.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/sheet.rs b/crates/engine/src/cmd/sheet.rs index 39976b67..d04d2fe0 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -274,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() @@ -331,16 +337,23 @@ 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 regions = sh.flat_regions()?; + for h in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) { + // 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, "OUTLINE"); + w(10, &f(center.x)); + w(20, &f(center.y)); + w(40, &f(*radius)); + continue; } } - } - for h in &sh.holes { - for seg in &h.outer.segs { + 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 { diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 3bfe4c6b..524eff6d 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -421,3 +421,328 @@ fn flat_dxf_preserves_exact_cutout_entities() { 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 3a390268..35fe4a68 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -9,6 +9,10 @@ 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; +use dimensioned::draw as draw_dimensioned; + /// The `connect` parameter every drawing command takes (see `connect_points`). macro_rules! connect_doc { () => { @@ -29,6 +33,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!())), + CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned) + .enabled(in_sketch) + .params("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"), CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point) .at("SKETCH", "CREATE") .icon("rect") @@ -355,7 +362,8 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { // 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 placed = s.placed_document(); + let st = solvecraft_doc::state_in_frame_at(&placed, &model, s.active_component, s.active_component_frame()); let mut found = None; let mut face_name = None; for b in &st.bodies { @@ -792,6 +800,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)?; @@ -804,6 +813,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; } @@ -818,13 +828,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) } @@ -1333,9 +1345,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()) } @@ -1384,6 +1405,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(); 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_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/frames.rs b/crates/engine/src/frames.rs index 67501f4d..a0a565be 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -88,38 +88,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 +120,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); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6907769..c3acc13e 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, }, @@ -159,9 +164,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). @@ -192,6 +200,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 +256,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 +307,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 +318,23 @@ 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; + } + } + } + } let w = Arc::new(w); if let Ok(mut c) = self.world_cache.lock() { *c = Some((self.revision, w.clone())); @@ -316,6 +345,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); @@ -421,21 +451,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(), } } @@ -469,6 +501,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); diff --git a/crates/engine/src/occurrences.rs b/crates/engine/src/occurrences.rs new file mode 100644 index 00000000..136a158d --- /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.body_component(&b.name, b.feature) == 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/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..eafac71e 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 @@ -674,51 +677,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/lib.rs b/crates/kernel/src/lib.rs index f0e8e878..38558a9a 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -64,6 +64,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/ops.rs b/crates/kernel/src/ops.rs index 045f48ba..10a10d01 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -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. diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 2c29606f..a6e1f280 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) @@ -707,3 +915,89 @@ 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 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/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/model.rs b/crates/sketch/src/model.rs index 2b5a0079..91981f70 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -233,6 +233,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 +298,9 @@ impl ConstraintKind { | DistanceY { value, .. } | PointLineDistance { value, .. } | Length { value, .. } + | LineAngle { value, .. } + | ArcSweep { value, .. } + | SlotOverallLength { value, .. } | Radius { value, .. } | Diameter { value, .. } | ArcLength { value, .. } @@ -298,6 +317,9 @@ impl ConstraintKind { | DistanceY { value, .. } | PointLineDistance { value, .. } | Length { value, .. } + | LineAngle { value, .. } + | ArcSweep { value, .. } + | SlotOverallLength { value, .. } | Radius { value, .. } | Diameter { value, .. } | ArcLength { value, .. } @@ -307,7 +329,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 +350,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 +363,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 +402,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); } @@ -694,11 +717,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 +746,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 +793,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(()) } diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index b1779f56..1a480b70 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -249,6 +249,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 }; @@ -433,6 +444,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 } => { @@ -483,6 +506,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, .. } => { @@ -614,14 +643,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); } @@ -846,6 +882,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() { @@ -866,6 +916,34 @@ 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 { @@ -1002,6 +1080,17 @@ 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::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)), @@ -1031,3 +1120,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/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..e4ecaf35 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 { @@ -59,6 +68,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 +84,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 } } } @@ -188,7 +202,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 +454,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 +496,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) @@ -598,9 +620,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); + } } } @@ -1004,7 +1126,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { 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), @@ -1082,9 +1204,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 +1243,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 +1321,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 +1350,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 +1366,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 +1403,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 +1518,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 +1554,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 +1599,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 +1622,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 }) + }, ); } 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..4e75ff74 --- /dev/null +++ b/crates/ui-egui/src/configuration_rules_ui.rs @@ -0,0 +1,356 @@ +//! 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(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 cc936c9f..5b12284b 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 { .. } @@ -292,6 +296,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), @@ -504,6 +509,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); @@ -681,7 +734,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" => { @@ -722,8 +775,14 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { start_rename(app, what, at); } "ui.properties" => { - let command = if p.get("component").is_some() { "component.properties" } else { "inspect.measure" }; - let params = if command == "component.properties" { p.clone() } else { json!({ "bodies": bodies }) }; + 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"] @@ -798,15 +857,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" => {} @@ -948,6 +1019,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)); @@ -994,6 +1071,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, @@ -1424,14 +1505,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() }); 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 520b081d..657047d1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -282,7 +282,7 @@ 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, } } diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index b55b42e1..0fd328a5 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -119,7 +119,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { && !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. })); let state = app.session.world_state(); let existing = app.session.selection.iter().find_map(|sel| match sel { - Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.body_look(name, b.feature)), + Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.model_body_look(b)), Sel::Face { body, index, .. } => state.body(body).and_then(|b| { app.session .doc @@ -127,7 +127,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { .into_iter() .find(|(i, _)| i == index) .map(|(_, look)| look) - .or_else(|| app.session.doc.body_look(body, b.feature)) + .or_else(|| app.session.doc.model_body_look(b)) }), _ => None, }); diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index d0a3bb5d..c0c33034 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()); } @@ -680,14 +699,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel run(app, error, "config.row", json!({"name": name, "values": { c: choice }})); } } - let id = egui::Id::new(("cfg", name, c)); - let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()); + let 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() { - ui.data_mut(|d| d.insert_temp(id, text.clone())); + rule_editor.drafts.insert(id.clone(), text.clone()); } if r.lost_focus() { - ui.data_mut(|d| d.remove::(id)); + rule_editor.drafts.remove(&id); if text != shown { run(app, error, "config.row", json!({"name": name, "values": { c: cell_value(c, &text) }})); } @@ -771,6 +790,7 @@ 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(e) = error { ui.label(""); ui.label(RichText::new(e.as_str()).color(t.error)); @@ -908,6 +928,82 @@ mod tests { assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm")); } + #[test] + fn configuration_compound_condition_and_rule_order_are_editable_in_dialog() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":1100})); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"parameters.change","params":{"name":"length","expression":"40 mm"}}}, + {"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}}, + {"start":"config.table"},{"widget":"Add or edit rule"}, + {"widget":"Always"},{"widget":"All"},{"widget":"Always"},{"widget":"Parameter"}, + {"widget":"Choose…"},{"widget":"length"},{"widget":"Save Rule"} + ])) + .unwrap(); + assert!( + matches!(&h.app.session.doc.configs.rules[0].condition,solvecraft_engine::doc::config::RuleCondition::All{conditions} if matches!(&conditions[0],solvecraft_engine::doc::config::RuleCondition::Parameter{..})) + ); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"config.rule","params":{"rule":{"id":"last","name":"Last drive","condition":{"type":"always"},"effect":{"type":"drive_parameter","parameter":"length","expression":"20 mm"}}}}}, + {"widget":"Preview Row"},{"widget":"Preview: Default"} + ])).unwrap(); + assert!(h.widget("length = 20 mm").is_some()); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm"); + h.run(&json!([{"widget":"↑"},{"widget":"Preview Row"}])).unwrap(); + assert_eq!(h.app.session.doc.configs.rules[0].id, "last"); + assert!(h.widget("length = 10 mm").is_some()); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm"); + } + + #[test] + fn configuration_rule_editor_creates_previews_edits_and_applies_driven_value() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":1000})); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"parameters.change","params":{"name":"length","expression":"40 mm"}}}, + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":"length","width":10,"height":10}}}, + {"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}}, + {"start":"config.table"}, + {"widget":"Add or edit rule"}, + {"widget":"Choose…"}, + {"widget":"length"}, + {"widget":"Save Rule"} + ])) + .unwrap(); + assert_eq!(h.app.session.doc.configs.rules.len(), 1); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm"); + let before = h.app.session.doc.clone(); + h.run(&json!([{"widget":"Preview Row"}])).unwrap(); + assert_eq!(h.app.session.doc, before); + h.run(&json!([{"widget":"Apply"}])).unwrap(); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "10 mm"); + assert!(h.app.session.doc.configuration_matches()); + h.run(&json!([ + {"widget":"10 mm","near":"Value"},{"key":"A","cmd":true},{"text":"20 mm"},{"widget":"Save Rule"}, + {"widget":"Apply"} + ])) + .unwrap(); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "20 mm"); + assert_eq!(h.app.session.doc.configs.rows[0].cells[0], "40 mm"); + let before = h.app.session.doc.clone(); + h.run(&json!([ + {"widget":"Drive parameter"},{"widget":"Range"}, + {"widget":"0 mm","near":"Minimum"},{"key":"A","cmd":true},{"text":"100 mm"},{"widget":"Save Rule"} + ])) + .unwrap(); + assert_eq!(h.app.session.doc, before, "failed rule edit is atomic"); + assert!( + h.widget("configuration rules: rule `New rule` restricts the final value").is_some() + || h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .any(|(_, n)| n.label().or(n.value()).is_some_and(|text| text.contains("restricts the final value"))) + ); + } + #[test] fn configuration_style_picker_applies_then_inherits_material() { let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap(); diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 60a1ca1f..1ee625c9 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -81,6 +81,7 @@ 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; let out_rev = app.session.revision; let out = DocSlot { session: std::mem::replace(&mut app.session, slot.session), @@ -140,6 +141,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) { let rev = app.session.revision; crate::dialogs::cancel(app); app.tool = None; + app.menu.props = None; app.session = session; app.session.invalidate_view(rev); app.cam = Camera::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34b7b49f..915e5f3b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -404,7 +404,13 @@ 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; + // 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(); } @@ -615,7 +621,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 64c4a7be..e74cd57c 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() 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..9a5587ba --- /dev/null +++ b/crates/ui-egui/src/occurrence_ui_tests.rs @@ -0,0 +1,182 @@ +use crate::context_menu::{self, Target}; +use crate::scenario::Harness; +use serde_json::json; +use solvecraft_engine::Sel; + +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/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 77925592..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). @@ -285,7 +327,9 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option 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).event_filter(egui::EventFilter { + // 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, @@ -326,11 +370,10 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option (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_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 240b7c1f..55be6973 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, }) 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 174311d8..64180911 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(), @@ -175,40 +187,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])}), @@ -217,7 +266,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}) @@ -229,14 +281,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) { @@ -268,6 +316,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..9c6d4dd2 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -214,10 +214,12 @@ 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); crate::theme::is_dark().hash(&mut h); app.session.active_sketch.hash(&mut h); @@ -264,12 +266,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 +290,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_look(b).map_or(1.0, |l| l.opacity); 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 +336,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); } @@ -370,7 +377,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; @@ -632,6 +639,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 +715,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 +741,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 +882,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 +908,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), } } @@ -1120,6 +1136,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 +1219,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 +1236,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 +1271,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() @@ -1936,7 +1954,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/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/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/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index b38a8e15..5e45fb53 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -28,3 +28,13 @@ consume an undo step. 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. diff --git a/docs/configurations.md b/docs/configurations.md index 43141b28..06edf4eb 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -36,8 +36,66 @@ face appearances keep their existing priority. Physical material changes affect Style columns target body names. Assignments remain stored when a row suppresses the feature that produces that body, and become visible when it returns. Component and face appearance -columns and conditional configuration rules are not yet supported. +columns are not yet supported. -Designs saved with these columns use file format 7. Older designs upgrade on opening without +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. + +Designs saved with rules use file format 8. 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/occurrence-workflows.md b/docs/occurrence-workflows.md new file mode 100644 index 00000000..d97c21df --- /dev/null +++ b/docs/occurrence-workflows.md @@ -0,0 +1,39 @@ +# 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 display at the active placement during authoring. Component-only activation keeps the first-placement behavior. Existing 3D wires retain their prior coordinate behavior; their mixed legacy coordinate storage needs a separate migration before occurrence authoring can apply consistently. + +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. diff --git a/docs/parity.md b/docs/parity.md index 32d6be9e..c067fdfe 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,16 +2,16 @@ 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): 331 / 541 commands live (61%).** SOLID + SKETCH: 186 / 291 (64%). Registered commands: 307. +**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: 623 / 1175 (53%). +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 | CONFIGURE | 3 | 3 | 100% | | SOLID | CONSTRUCT | 19 | 20 | 95% | | SOLID | INSPECT | 12 | 25 | 48% | | SOLID | INSERT | 6 | 11 | 55% | @@ -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,7 +29,7 @@ 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 | CONFIGURE | 3 | 3 | 100% | | SHEET METAL | CONSTRUCT | 19 | 20 | 95% | | SHEET METAL | INSPECT | 12 | 25 | 48% | | SHEET METAL | INSERT | 6 | 11 | 55% | @@ -42,7 +42,7 @@ 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 | CONFIGURE | 3 | 3 | 100% | | ASSEMBLY | CONSTRUCT | 19 | 20 | 95% | | ASSEMBLY | INSPECT | 12 | 25 | 48% | | ASSEMBLY | SELECT | 0 | 15 | 0% | @@ -56,7 +56,7 @@ 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 | CONFIGURE | 3 | 3 | 100% | | SURFACE | CONSTRUCT | 19 | 20 | 95% | | SURFACE | INSPECT | 12 | 25 | 48% | | SURFACE | INSERT | 6 | 11 | 55% | @@ -66,7 +66,7 @@ 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 | CONFIGURE | 3 | 3 | 100% | | MESH | CONSTRUCT | 19 | 20 | 95% | | MESH | INSPECT | 12 | 25 | 48% | | MESH | INSERT | 5 | 10 | 50% | @@ -87,7 +87,7 @@ 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 | CONFIGURE | 3 | 3 | 100% | | PLASTIC | CONSTRUCT | 19 | 20 | 95% | | PLASTIC | INSPECT | 12 | 26 | 46% | | PLASTIC | INSERT | 6 | 11 | 55% | @@ -166,6 +166,7 @@ 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 @@ -275,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 @@ -332,7 +334,6 @@ 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 › INSPECT › Validate - SOLID › INSPECT › Component Connection 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/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 From 0398a813c8c1524fa144dfe13cf5902feda5b43b Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sun, 11 Oct 2026 02:44:31 -0400 Subject: [PATCH 3/5] feat: strengthen sketches, feature editing, and independent assemblies --- README.md | 22 +- ROADMAP.md | 39 +- crates/doc/src/appearance.rs | 37 +- crates/doc/src/clipboard.rs | 371 ++++++++++ crates/doc/src/config.rs | 46 ++ crates/doc/src/config_clone.rs | 213 ++++++ crates/doc/src/config_rules.rs | 38 + crates/doc/src/config_styles.rs | 191 +++++ crates/doc/src/document.rs | 98 ++- crates/doc/src/eval.rs | 45 +- crates/doc/src/eval_more.rs | 49 +- crates/doc/src/format.rs | 294 +++++++- crates/doc/src/lib.rs | 5 +- crates/doc/src/naming.rs | 13 +- crates/doc/src/params.rs | 3 + crates/engine/src/cmd/browser.rs | 3 + crates/engine/src/cmd/component.rs | 65 +- crates/engine/src/cmd/component_clone.rs | 579 +++++++++++++++ .../src/cmd/component_clone_assembly.rs | 360 +++++++++ .../src/cmd/component_clone_assembly_tests.rs | 193 +++++ .../engine/src/cmd/component_clone_tests.rs | 393 ++++++++++ crates/engine/src/cmd/config.rs | 114 ++- crates/engine/src/cmd/config_style_tests.rs | 225 ++++++ crates/engine/src/cmd/edit.rs | 13 +- crates/engine/src/cmd/features.rs | 19 +- crates/engine/src/cmd/features_more.rs | 99 ++- crates/engine/src/cmd/features_more_tests.rs | 262 +++++++ crates/engine/src/cmd/inspect.rs | 24 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/sketch.rs | 7 +- crates/engine/src/cmd/sketch_modify.rs | 122 ++- crates/engine/src/cmd/sketch_modify_tests.rs | 150 ++++ crates/engine/src/cmd/sketch_support.rs | 214 ++++++ crates/engine/src/frames.rs | 53 +- crates/engine/src/lib.rs | 22 +- crates/kernel/src/emboss.rs | 473 ++++++++++++ crates/kernel/src/lib.rs | 2 + crates/kernel/src/ops.rs | 100 ++- crates/kernel/src/placement_tests.rs | 95 +++ crates/kernel/src/step.rs | 8 +- crates/sketch/src/freeform_offset.rs | 697 ++++++++++++++++++ crates/sketch/src/lib.rs | 2 + crates/sketch/src/model.rs | 7 +- crates/sketch/src/offset.rs | 44 ++ crates/ui-egui/src/canvas.rs | 155 +++- crates/ui-egui/src/configuration_rules_ui.rs | 12 + crates/ui-egui/src/context_menu.rs | 3 + crates/ui-egui/src/dialogs.rs | 288 ++++++-- crates/ui-egui/src/dialogs_appearance.rs | 43 +- crates/ui-egui/src/dialogs_assembly.rs | 12 +- crates/ui-egui/src/dialogs_motion.rs | 131 +++- crates/ui-egui/src/extrude_edit_tests.rs | 280 +++++++ crates/ui-egui/src/lib.rs | 8 +- crates/ui-egui/src/preselect_tests.rs | 175 +++++ crates/ui-egui/src/revolve_edit_tests.rs | 221 ++++++ crates/ui-egui/src/sketch_corner_ui_tests.rs | 36 + crates/ui-egui/src/sketch_tools.rs | 8 +- crates/ui-egui/src/viewport.rs | 15 +- .../ui-egui/src/viewport_appearance_tests.rs | 54 ++ docs/book/src/guide/assemblies.md | 7 + docs/book/src/guide/features.md | 16 + docs/book/src/guide/files.md | 5 + docs/book/src/guide/sketching.md | 14 + docs/configurations.md | 48 +- docs/occurrence-workflows.md | 8 + 65 files changed, 7082 insertions(+), 268 deletions(-) create mode 100644 crates/doc/src/config_clone.rs create mode 100644 crates/doc/src/config_styles.rs create mode 100644 crates/engine/src/cmd/component_clone.rs create mode 100644 crates/engine/src/cmd/component_clone_assembly.rs create mode 100644 crates/engine/src/cmd/component_clone_assembly_tests.rs create mode 100644 crates/engine/src/cmd/component_clone_tests.rs create mode 100644 crates/engine/src/cmd/config_style_tests.rs create mode 100644 crates/engine/src/cmd/sketch_support.rs create mode 100644 crates/kernel/src/emboss.rs create mode 100644 crates/kernel/src/placement_tests.rs create mode 100644 crates/sketch/src/freeform_offset.rs create mode 100644 crates/ui-egui/src/extrude_edit_tests.rs create mode 100644 crates/ui-egui/src/revolve_edit_tests.rs create mode 100644 crates/ui-egui/src/viewport_appearance_tests.rs diff --git a/README.md b/README.md index 6694fd22..2d25820d 100644 --- a/README.md +++ b/README.md @@ -23,21 +23,25 @@ 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. - **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). +- **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. +- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns, + exact line/arc/circle DXF outlines and bend tables; plastic enclosure features and rules. - **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 a54854aa..5b1565d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Remaining workflow repairs: exact free-form Extend intersections, associative free-form Offset, and consistent occurrence-frame 3D wire authoring | in progress | 4 | +| 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. Remaining workflow repairs: mixed offset junctions, periodic closed spline seams, and consistent occurrence-frame 3D wire authoring | in progress | 4 | | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 | | 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; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | -| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 | +| 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. Left: general curved-face emboss, automatic cylinder target inference, 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. 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). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Left: component/face style columns | in progress | 4 | -| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/8"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +| 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. Left: certified sketch-side/imported face lineage and 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/9"`, 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 @@ -106,6 +106,23 @@ headline counts in-scope tabs only and lists the deferred ones separately. 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. +- 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 rotator direction and To Object value interaction remain follow-on usability repairs. +- 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. Select the face explicitly and capture pending positions first. 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. @@ -127,17 +144,25 @@ headline counts in-scope tabs only and lists the deferred ones separately. checked occurrence paths. Active nested occurrences author planar sketches and solids in their selected frame. Pending moves compose through parents; dialog cancellation restores its selected move. Source materials and body/face appearances follow transformed instances. - Shared nested definitions remain shared, and legacy 3D wire authoring and deep Paste New - need further work. See [occurrence workflows](docs/occurrence-workflows.md). + 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. Creating a component from a body can change its historical + implicit Join membership; preserving that material through ownership transfers remains open. + Legacy 3D wire authoring needs further work. See [occurrence workflows](docs/occurrence-workflows.md). - Configuration rules: ordered typed conditions can drive parameters and suppressions or restrict values. Preview explains effective values without modifying the document; applying rules validates rows atomically. Length expressions retain their authoring units across document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering. - Component and face style columns remain open. See [configurations](docs/configurations.md). + 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. + Partial-depth Cut and Intersect still lack retained machining metadata; later sheet rebuilds + can restore removed material. Their continuity repair remains open. - Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 89fcfce7..7fefc281 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -45,6 +45,13 @@ 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, +} + +fn is_false(value: &bool) -> bool { + !*value } /// The look a body was imported with, carried through later features (which build new kernel @@ -134,6 +141,14 @@ impl Document { pub fn model_body_look(&self, body: &crate::ModelBody) -> Option { self.body_look(body.definition_name(), body.feature) } + /// 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 { @@ -173,6 +188,24 @@ impl Document { }; 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, + }; + 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); @@ -185,7 +218,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/clipboard.rs b/crates/doc/src/clipboard.rs index 81272631..add9db0a 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,371 @@ 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, +) { + use crate::construct::GeoRef; + if depth > 64 { + return; + } + 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), + _ => {} + } +} + +fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap, names: &BTreeMap, bodies: &BTreeMap, depth: usize) { + if depth > 64 { + return; + } + 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: Some(n), .. } => *n = remap_topology(n, ids, bodies), + _ => { + for r in p.refs_mut() { + remap_geo(r, ids, names, bodies, depth + 1); + } + } + } +} + +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, .. } => { + 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 97fa631c..e0e2bc47 100644 --- a/crates/doc/src/config.rs +++ b/crates/doc/src/config.rs @@ -9,6 +9,12 @@ 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_clone.rs"] +mod clone; +pub use clone::*; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -21,6 +27,10 @@ pub enum Column { 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)] @@ -132,6 +142,12 @@ impl Document { 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()) + } } } @@ -142,6 +158,8 @@ impl Document { 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()), } } @@ -194,6 +212,32 @@ impl Document { 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, + }); + } + } } } next.configs.active = Some(row.to_string()); @@ -228,6 +272,8 @@ impl Document { } 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), }) } } diff --git a/crates/doc/src/config_clone.rs b/crates/doc/src/config_clone.rs new file mode 100644 index 00000000..7ccb8eba --- /dev/null +++ b/crates/doc/src/config_clone.rs @@ -0,0 +1,213 @@ +//! 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; + 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.), + }), + ], + 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, + }), + ], + 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 index f186fbf4..ebd01a3a 100644 --- a/crates/doc/src/config_rules.rs +++ b/crates/doc/src/config_rules.rs @@ -377,6 +377,44 @@ impl ConfigRule { } }); } + 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 { diff --git a/crates/doc/src/config_styles.rs b/crates/doc/src/config_styles.rs new file mode 100644 index 00000000..577e5c38 --- /dev/null +++ b/crates/doc/src/config_styles.rs @@ -0,0 +1,191 @@ +//! 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, +} +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 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(()) + } +} + +/// 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") { + let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?; + if fields.len() != 4 + || fields.keys().any(|key| !["body", "origin_feature", "name", "point"].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(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())); + } + } + } + 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()))?; + 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())); + } + 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 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 f9085052..3ed7742a 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -161,6 +161,85 @@ 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_feature: u64) -> Result { + let owner = doc.body_component(&self.body, body_feature); + 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, feature: u64) -> Result { + let owner = doc.body_component(&self.body, feature); + let mut frame = Self::component_frame(doc, owner, &self.path)?; + if let Some(offset) = doc.body_offsets.get(&self.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")] @@ -437,13 +516,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, }, @@ -1220,6 +1301,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 +1337,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); @@ -1595,17 +1680,12 @@ impl Document { let mut st = state.clone(); for b in &mut st.bodies { let c = self.body_color(b); - let look = self.model_body_look(b); 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)); @@ -1752,9 +1832,13 @@ impl Document { .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)); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index bfd25e24..626b7229 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -632,7 +632,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) { @@ -785,6 +788,11 @@ 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 apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation, targets: &[String]) -> Result<()> { // Participants: the feature's own targets, else its explicit participants, else the bodies @@ -876,7 +884,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)) { @@ -1415,11 +1423,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 @@ -2092,7 +2101,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(()) } @@ -2105,7 +2121,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()) { @@ -2367,7 +2383,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); @@ -2626,12 +2642,14 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: 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(()) @@ -2642,6 +2660,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 }; @@ -2655,13 +2674,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(()) @@ -2857,8 +2878,8 @@ 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(()) diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 4c7a0ec8..3c8e5f73 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -2,18 +2,65 @@ //! kernel's extrude, boolean, offset and transform operations). use super::*; +use crate::EmbossFace; use crate::apply_point; 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.feature)?; + 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.feature)?; + 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() { diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 2ee77cfe..9017fb7e 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -25,13 +25,16 @@ //! 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. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 8; +pub const FORMAT: u32 = 9; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -44,7 +47,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; 7] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(())]; +const JSON_STEPS: [JsonStep; 8] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| 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, @@ -136,6 +139,9 @@ pub fn read(s: &str) -> Result { } check_numbers(&v, 0)?; crate::config::validate_rules_json(&v)?; + crate::config::validate_styles_json(&v)?; + validate_emboss_targets(&v)?; + validate_freeform_offsets(&v)?; for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) { step(&mut v)?; } @@ -151,12 +157,125 @@ pub fn read(s: &str) -> Result { return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name))); } } + 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}")))?; + } + } // 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) } +/// 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(()) +} + +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 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) + || record.get("certificate").and_then(Value::as_u64) != Some(1) + || record.get("forward").and_then(Value::as_bool).is_none() + || record.get("closed").and_then(Value::as_bool).is_none() + { + 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; @@ -216,6 +335,173 @@ fn normalise(d: &mut Document) -> Result<()> { mod tests { use super::*; + #[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 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}; @@ -269,7 +555,7 @@ mod tests { let roundtrip = read(&value.to_string()).unwrap(); assert_eq!(roundtrip, d); assert_eq!(serde_json::to_value(&roundtrip.configs).unwrap(), original_table); - assert_eq!(roundtrip.format, "solvecraft/8"); + assert_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"}]); @@ -322,7 +608,7 @@ mod tests { sketch.add_constraint(ConstraintKind::SlotOverallLength { a, b, value: 10.0 }, Some(overall)).unwrap(); d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); let mut saved: Value = serde_json::from_str(&d.to_json()).unwrap(); - assert_eq!(saved["format"], "solvecraft/8"); + assert_eq!(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"); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index be6802b2..248942da 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -9,6 +9,7 @@ pub mod appearance; mod assembly; pub mod canvas; mod clipboard; +pub use clipboard::{clone_expression, remap_topology}; pub mod config; pub mod construct; mod document; @@ -28,8 +29,8 @@ 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, state_in_frame_at, world_state, + EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, parse_metric_thread, + pattern_matrices, state_in_frame, state_in_frame_at, through_all_distance, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index a60c1478..d2c92816 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); diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index e7ce630d..fde2cebe 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)); diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 008dc4ac..13da3a96 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -486,6 +486,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { 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(); + } _ => {} } } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index f7455944..cbeaabd7 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[ 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; 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: copied with its own features into a new, independent component; occurrence?, path?: selected source placement; translate?: [x,y,z]"), + 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; 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)"), @@ -82,7 +82,7 @@ fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) Ok(v) } -fn valid_position(m: &Mat) -> bool { +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()) @@ -229,6 +229,7 @@ fn delete(s: &mut Session, p: &Value) -> Result { 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)); @@ -351,65 +352,7 @@ fn paste_new(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "copy transform exceeds supported numeric range")); } let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent); - let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), 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; - } - } - if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) { - o.transform = transform; - } - Ok(json!({"component": new_id, "features": new_ids})) -} - -/// 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), - } - } - } - Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)), - _ => {} - } + super::component_clone::paste(s, comp, occ, transform, parent, p) } pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs new file mode 100644 index 00000000..5acf8d5d --- /dev/null +++ b/crates/engine/src/cmd/component_clone.rs @@ -0,0 +1,579 @@ +//! 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.body_component(&b.name, b.feature)) && !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 comp = source.body_component(&body.name, body.feature); + let offset = source.body_offsets.get(&body.name).copied().unwrap_or(solvecraft_doc::IDENTITY); + 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 base = before.as_ref().and_then(|st| st.body(&body.name)).unwrap_or(body); + 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(&comp).ok_or_else(|| bad(CMD, "missing assigned component"))?; + 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::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 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.body_component(&b.name, b.feature))) + .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 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"))?; + } + face.point = solvecraft_doc::apply_point(source.body_offsets.get(&face.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(); + 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)); + } + _ => {} + } + } + } + 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.body_component(&b.name, b.feature))).count(); + let copied_components: BTreeSet<_> = components.values().copied().collect(); + let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.body_component(&b.name, b.feature))).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.body_component(&b.name, b.feature))) { + 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.body_offsets.get(&body.name).unwrap_or(&solvecraft_doc::IDENTITY))?; + let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.body_offsets.get(new).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..1273710e --- /dev/null +++ b/crates/engine/src/cmd/component_clone_tests.rs @@ -0,0 +1,393 @@ +#![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), + }; + 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), + }; + 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); +} diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index 993f1b9b..7e61db30 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -28,8 +28,66 @@ pub static COMMANDS: &[CommandSpec] = &[ ]; fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { - if ["param", "feature", "material", "appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 { - return Err(bad(cmd, "give exactly one of `param`, `feature`, `material`, or `appearance`")); + 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")); + } + 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 names = solvecraft_doc::naming::face_names(body); + let name = names.get(index).map(|name| solvecraft_doc::naming::strip_piece(name).to_string()).ok_or_else(|| bad(cmd, "face name missing"))?; + let origin_feature = name + .strip_prefix('F') + .and_then(|name| name.split_once(':')) + .and_then(|(id, _)| id.parse::().ok()) + .ok_or_else(|| bad(cmd, "face lineage missing"))?; + let inverse = solvecraft_doc::mat_inverse(&body.source_transform).ok_or_else(|| bad(cmd, "invalid definition placement transform"))?; + let target = solvecraft_doc::config::ConfigFaceTarget { + body: body.definition_name().into(), + origin_feature, + name, + point: solvecraft_doc::apply_point(&inverse, point), + }; + 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) { @@ -53,7 +111,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 { @@ -85,6 +145,18 @@ fn configure(s: &mut Session, p: &Value) -> Result { 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; @@ -102,7 +174,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, "rules": t.rules, "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 { @@ -154,7 +245,9 @@ fn row(s: &mut Session, p: &Value) -> Result { Value::String(t) => t.clone(), Value::Bool(b) => b.to_string(), Value::Number(n) => n.to_string(), - Value::Object(_) if k.starts_with("appearance:") => serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?, + 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 { @@ -170,7 +263,7 @@ fn row(s: &mut Session, p: &Value) -> Result { || match c { Column::Param(n) => n == k, Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == *k), - Column::Material(_) | Column::Appearance(_) => false, + Column::Material(_) | Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_) => false, } }) .ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?; @@ -178,7 +271,7 @@ fn row(s: &mut Session, p: &Value) -> Result { 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(_)) => { + 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(), @@ -303,12 +396,14 @@ fn activate(s: &mut Session, p: &Value) -> Result { 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) } @@ -320,6 +415,11 @@ fn preview(s: &mut Session, p: &Value) -> Result { Ok(out) } +#[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}; 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..2580ea1d --- /dev/null +++ b/crates/engine/src/cmd/config_style_tests.rs @@ -0,0 +1,225 @@ +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, + 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 }; + 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 }; + 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/edit.rs b/crates/engine/src/cmd/edit.rs index d6a975a4..89497f14 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -355,6 +355,9 @@ fn redefine(s: &mut Session, p: &Value) -> Result { // 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(); trial.doc_mut().marker = Some(idx); @@ -362,7 +365,15 @@ fn redefine(s: &mut Session, p: &Value) -> Result { (spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; let r = (spec.run)(&mut trial, ¶ms)?; let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; - let new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; + 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(); + } if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) { return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid))); } diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 13641d0f..88082a8f 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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.feature).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()) { diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index d98a6c23..c7ebedc6 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 (required for wrapped orthogonal line profiles)"), CommandSpec::new("solid.replace_face", "Replace Face", replace_face) .at("SOLID", "MODIFY") .icon("offset_face") @@ -143,12 +143,97 @@ 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 original = s.model.state(); + let owner = s.doc.body_component(name, selected.feature); + 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"))?; + if path.len() > 64 { + return Err(bad(cmd, "selected cylinder placement is too deep")); + } + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component)); + if author_path.len() > 64 { + return Err(bad(cmd, "author placement is too deep")); + } + 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 { + path.clone() + } else if sketch_owner == s.active_component { + author_path.clone() + } else { + s.doc.first_path(sketch_owner) + }; + if path.iter().chain(&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")); + } + let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?; + let at = solvecraft_doc::apply_point(&inverse, at); + targets = vec![name.to_owned()]; + Some(solvecraft_doc::EmbossFace { body: name.to_owned(), at, path, author_path }) + } + _ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")), + } + } else { + None + }; + add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face }) } fn face_points(p: &Value, k: &str, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 078464e1..c4b9bb4c 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -81,6 +81,268 @@ 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 in [0., 80.] { + 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() { + 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 in [false, 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", &json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1})).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() { + 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", &json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})) + .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() { + 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]})); + run(&mut s, "component.move_bodies", json!({"bodies":["Sleeve"],"component":holder})); + 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(); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index dad2533e..23d46a18 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -401,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 }); } } for c in &comps { @@ -414,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!({ diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3a8d77ff..f8e92073 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,8 @@ mod browser; mod clipboard; mod component; +mod component_clone; +mod component_clone_assembly; mod construct; mod delete; mod edit; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 35fe4a68..04e05866 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -223,7 +223,12 @@ 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(", ")))); } diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 69575862..372f3c8f 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -12,6 +12,9 @@ use super::{CommandSpec, in_sketch}; 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("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"), + 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") @@ -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) } @@ -365,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()) { @@ -386,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})) @@ -809,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(); @@ -855,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(); @@ -947,6 +1000,29 @@ 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, _| { + 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"))?; + sk.refresh_offsets()?; + 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 5f13604d..b1940a9c 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -183,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(); @@ -563,3 +615,101 @@ 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()); +} + +#[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_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 a0a565be..8a882e41 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)], @@ -333,7 +337,34 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { // component is active. Body-relative picks still use the picked body's frame. let mut context = s.scratch(); context.active_component = feature.component; - let inner = to_local(&context, cmd, params.get("params")?)?; + 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); @@ -359,6 +390,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/lib.rs b/crates/engine/src/lib.rs index c3acc13e..86d8a4b8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -513,10 +513,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())); } @@ -534,7 +550,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/kernel/src/emboss.rs b/crates/kernel/src/emboss.rs new file mode 100644 index 00000000..9d0c337f --- /dev/null +++ b/crates/kernel/src/emboss.rs @@ -0,0 +1,473 @@ +//! 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 +} + +/// 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) +} + +/// 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) +} + +fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Region2], depth: f64) -> 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 = w - a * w.dot(a); + if (radial.len() - r).abs() > tol * 10.0 || plane.normal().dot(radial / r) < 1.0 - 1e-8 { + 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 mut angular = None; + for e in f.edge_iter() { + if let Some(Some(mid)) = crate::offset::arc_mid(&e, tol * 10.0) { + let am = angle(mid, o, a, radial, tangent); + let aa = unwrap(angle(from_p3(e.front().point()), o, a, radial, tangent), am); + let ab = unwrap(angle(from_p3(e.back().point()), o, a, radial, tangent), am); + angular = Some((aa.min(ab), aa.max(ab))); + break; + } + } + let Some((alo, ahi)) = angular 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"))?; + 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); + 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 map2 = |p: Vec2| Vec2::new(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 || 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)?; + 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(result) + }) +} + +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 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/lib.rs b/crates/kernel/src/lib.rs index 38558a9a..a464e526 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -20,6 +20,7 @@ mod build; mod coplanar; mod curveblend; mod delete_face; +mod emboss; mod freeform; pub mod fuzz; mod heal; @@ -54,6 +55,7 @@ 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 delete_face::delete_faces; +pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at}; 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; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 10a10d01..105e9f15 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)] @@ -530,16 +530,93 @@ 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. + Body::new(placed_topology(&body.solid, mat)?).map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned())) }) } +// 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()); @@ -556,19 +633,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 @@ -604,3 +670,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/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/sketch/src/freeform_offset.rs b/crates/sketch/src/freeform_offset.rs new file mode 100644 index 00000000..9f5cc434 --- /dev/null +++ b/crates/sketch/src/freeform_offset.rs @@ -0,0 +1,697 @@ +//! 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 natural cubic interpolant. The endpoint interpolation theorem then +//! bounds their distance everywhere, rather than just at selected samples. +use crate::{CurveKind, Sketch, SketchError, fit_beziers}; +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; +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, tolerance: f64) -> Result<(Vec, usize, bool)> { + 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(&points); + let mut budget = 65_536; + for (h, c) in restricted.iter().zip(&cubics) { + certify(h, c, d, tolerance, 0, &mut budget)?; + } + 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 + || record.certificate != 1 + || 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 } + if 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; + 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, OFFSET_TOLERANCE) { + 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 }, None)?; + candidate.freeform_offsets.push(FreeformOffset { + source: source_id, + output: candidate.curves[output].id.clone(), + forward, + distance, + divisions, + pieces, + closed, + tolerance: OFFSET_TOLERANCE, + certificate: 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(); + for (record_index, record) in self.freeform_offsets.clone().into_iter().enumerate() { + if record.certificate != 1 + || 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, record.tolerance) { + 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 } if 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 }; + 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(); + 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); + } +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 3079d6db..07c3ddcd 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -10,6 +10,7 @@ mod curves; mod dims; +mod freeform_offset; mod linalg; mod link; mod model; @@ -24,6 +25,7 @@ pub use curves::{ MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, spline_end_tangent, spline_point, spline_polyline, }; 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; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 91981f70..9b0c3f27 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -492,6 +492,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, @@ -521,6 +524,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(), @@ -886,7 +890,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. @@ -1060,6 +1064,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() { 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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ee171eb3..a4ab9738 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,11 +194,40 @@ 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 { .. }); @@ -170,7 +238,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, 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 +252,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,7 +280,10 @@ 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) @@ -216,7 +292,17 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, } 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 +327,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,11 +429,39 @@ 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 diff --git a/crates/ui-egui/src/configuration_rules_ui.rs b/crates/ui-egui/src/configuration_rules_ui.rs index 4e75ff74..0fcffab3 100644 --- a/crates/ui-egui/src/configuration_rules_ui.rs +++ b/crates/ui-egui/src/configuration_rules_ui.rs @@ -210,6 +210,18 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option, 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!( diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 5b12284b..4bfa024b 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -652,6 +652,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)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 657047d1..7dd8afda 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). @@ -288,7 +296,17 @@ fn fits(a: Accept, s: &Sel) -> bool { 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 { @@ -1055,6 +1085,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; } } @@ -1284,17 +1317,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { input_rows(&mut d, ui); match &mut d.kind { Kind::Sketch => {} - Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, start, all } => { + 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(); @@ -1304,8 +1342,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; @@ -1323,9 +1371,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"); @@ -1337,7 +1394,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; @@ -1541,6 +1598,11 @@ 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("Pick a cylinder wall to wrap the profiles around it. Leave Target Face empty for planar embossing.").wrap(), + ); + ui.end_row(); } Kind::Align { flip } => { // Where on the picked face or edge to snap: as picked (a face turns to meet @@ -1873,7 +1935,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); } } @@ -1886,12 +1948,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) @@ -1899,18 +1979,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, }) @@ -1918,9 +2006,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 })]; } } @@ -2086,17 +2178,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) @@ -2111,6 +2244,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.feature).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; @@ -2126,13 +2284,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() { @@ -2143,6 +2301,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(); @@ -2165,21 +2329,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) } @@ -2361,7 +2522,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")?; @@ -2783,6 +2949,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.body_component(&face.body, body.feature); + 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() }), @@ -2840,16 +3018,21 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option) -> Option { 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 }; 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, }; @@ -3289,7 +3486,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(); @@ -3299,6 +3496,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 0fd328a5..470ecf9d 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -118,17 +118,20 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { 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(|b| app.session.doc.model_body_look(b)), - Sel::Face { body, index, .. } => state.body(body).and_then(|b| { - app.session - .doc - .face_colors(b) - .into_iter() - .find(|(i, _)| i == index) - .map(|(_, look)| look) - .or_else(|| app.session.doc.model_body_look(b)) - }), + 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, }); ( @@ -341,7 +344,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel ui.end_row(); row_label(ui, "Opacity"); ui.spacing_mut().slider_width = 110.0; - if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).fixed_decimals(2)).changed() { + // 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(); @@ -590,6 +594,23 @@ mod tests { 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(); diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 07d08727..b8ac856d 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -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 c0c33034..9d65fc4d 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -631,10 +631,22 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel "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: n, "delete": true })); + run( + app, + error, + "config.column", + json!({ key: if kind == "face_appearance" {c.as_str()} else {n}, "delete": true }), + ); } }); } @@ -661,7 +673,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel for (c, val) in tb.columns.iter().zip(vals) { ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { let (kind, _) = column_parts(c); - let shown = if kind == "appearance" { + let appearance = matches!(kind, "appearance" | "component_appearance" | "face_appearance"); + let shown = if appearance { solvecraft_engine::doc::config::appearance_cell(val) .ok() .flatten() @@ -671,11 +684,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel } else { val.clone() }; - let style = matches!(kind, "material" | "appearance"); + 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 { "Default material" }).clicked() { + 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(); } @@ -757,6 +779,17 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel } } } + 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| { @@ -766,6 +799,43 @@ 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| { + let names = solvecraft_engine::doc::naming::face_names(body); + if let Ok(faces) = body.body.faces((body.body.size() * 2e-3).max(1e-3)) { + let mut seen = std::collections::BTreeSet::new(); + for face in faces { + let Some(name) = names.get(face.index) else { continue }; + let name = solvecraft_engine::doc::naming::strip_piece(name); + if !seen.insert(name.to_string()) { + continue; + } + let Some((id, role)) = name.strip_prefix('F').and_then(|name| name.split_once(':')) else { continue }; + let Ok(origin_feature) = id.parse() else { continue }; + let target = solvecraft_engine::doc::config::ConfigFaceTarget { + body: body.name.clone(), + origin_feature, + name: name.into(), + point: face.centroid, + }; + if app.session.doc.certify_config_face(&target).is_err() { + continue; + } + if ui.button(role).clicked() { + pick = Some(json!({"face_appearance":{"body":body.name,"point":face.centroid}})); + ui.close(); + } + } + } + }); + } + }); }); }); if let Some(p) = pick { @@ -779,6 +849,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel 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")] }) }, @@ -791,6 +865,20 @@ 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)); @@ -1004,6 +1092,41 @@ mod tests { ); } + #[test] + fn configuration_component_and_face_pickers_start_fresh_and_clear_inherited() { + for (component, width) in [(true, 1400), (false, 900)] { + let mut h = crate::scenario::Harness::new(); + h.call("ui.resize", json!({"width":width,"height":720})); + if component { + h.call("engine.execute", json!({"command":"component.create","params":{"name":"Part"}})); + } + h.run(&json!([ + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10,"body_name":"Block"}}}, + {"start":"config.table"}, {"widget":"Add Column"} + ])) + .unwrap(); + if component { + h.step(&json!({"widget":"Part (component appearance)"})).unwrap(); + assert!(h.widget("Component: Part").is_some()); + } else { + h.run(&json!([{"widget":"Face Appearance"},{"widget":"Faces on Block"},{"widget":"+z"}])).unwrap(); + assert!(h.widget("Face: Block / +z").is_some()); + } + assert_eq!(h.app.session.doc.configs.rows.len(), 1); + h.run(&json!([{"widget":"Library"},{"widget":"ABS - Red"},{"widget":"Apply"}])).unwrap(); + if component { + assert_eq!(h.app.session.doc.appearances.components.values().next().unwrap().name, "ABS - Red"); + } else { + assert_eq!(h.app.session.doc.appearances.faces[0].look.name, "ABS - Red"); + assert!(h.app.session.doc.appearances.faces[0].strict_name); + } + h.run(&json!([{"widget":"Library"},{"widget":"Follow inherited appearance"},{"widget":"Apply"}])).unwrap(); + assert!(h.app.session.doc.appearances.components.is_empty()); + assert!(h.app.session.doc.appearances.faces.is_empty()); + crate::scenario::check(&mut h, &json!({"windows_fit":{"max_width":560}})).unwrap(); + } + } + #[test] fn configuration_style_picker_applies_then_inherits_material() { let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap(); 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 915e5f3b..bcaa663c 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,6 +65,8 @@ mod preselect_tests; pub mod preview; pub mod recovery_ui; pub mod ref_images; +#[cfg(test)] +mod revolve_edit_tests; pub mod scenario; pub mod selection; pub mod shortcut_box; @@ -548,13 +552,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; } diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index afc2ea45..d65b61d9 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -8,12 +8,187 @@ use solvecraft_engine::{Sel, Session}; use crate::dialogs::{Dialog, Kind, apply_commands}; use crate::{Services, SolveApp}; +#[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/revolve_edit_tests.rs b/crates/ui-egui/src/revolve_edit_tests.rs new file mode 100644 index 00000000..5a8abd55 --- /dev/null +++ b/crates/ui-egui/src/revolve_edit_tests.rs @@ -0,0 +1,221 @@ +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(); + let before_a = solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume; + assert!((before_a - (2000.0 - 490.873852)).abs() < 0.2); + 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!((solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume - before_a).abs() < 1e-7); + 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"); + 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/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs index 88648237..397cc102 100644 --- a/crates/ui-egui/src/sketch_corner_ui_tests.rs +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -1,6 +1,42 @@ use super::*; use solvecraft_engine::sketch::CurveKind; +#[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"] { diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 55be6973..a40a8070 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -647,7 +647,13 @@ 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)})); } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 9c6d4dd2..15b579f8 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, @@ -290,7 +294,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.model_body_look(b).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) { @@ -1086,13 +1090,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) { 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/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index e837086b..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 diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 555f0561..f27211fe 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -18,6 +18,22 @@ 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. + +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; omitting the face retains the planar emboss behavior. + 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. diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 663bd9c2..f617c95f 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -17,3 +17,8 @@ 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 9 for semantic configuration styles, associative free-form offsets +and explicit cylindrical emboss selections. Older geometry and expressions retain their +meaning during upgrade; missing new fields use defaults. Association records and occurrence +paths are bounded and checked before they can alter geometry. diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index d10bf732..c5d222b7 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -15,6 +15,20 @@ fillet, chamfer, offset (O), mirror, patterns, move/copy and scale. **Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that follows later changes. Sketching on a face projects its edges automatically. +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 offsets can be offset again, including bounded refinement after a source edit. +A closed offset uses a natural cubic spline whose seam can have different end tangents; +offsetting that result again is refused when the seam has no unique normal. + ## 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 index 06edf4eb..3de62a14 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -1,7 +1,8 @@ # Design configurations The configuration table stores named design variants. Add parameter, feature suppression, -physical material, and body appearance columns with `config.configure` or `config.column`. +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. @@ -34,9 +35,46 @@ face appearances keep their existing priority. Physical material changes affect `delete` to remove it. Names must be unique. To remove a style column, use `config.column` with `material` or `appearance` set to the target body and `delete: true`. -Style columns target body names. Assignments remain stored when a row suppresses the feature -that produces that body, and become visible when it returns. Component and face appearance -columns are not yet supported. +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 use a definition body and semantic lineage, never 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. 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. 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. + +Independent component copies duplicate mapped configuration columns, row values, and rules +without retargeting the source. Copying a strict configured face from a foreign body frozen +as an import currently fails explicitly because imported face lineage is not certified. 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 @@ -96,6 +134,6 @@ value lists support up to 128 expressions. These bounds are checked before saved decoded. Driven appearance/material rules and repeated fixed-point evaluation are outside the current rule scope. -Designs saved with rules use file format 8. Older designs upgrade on opening without +Designs saved with component or strict face styles use file format 9. 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/occurrence-workflows.md b/docs/occurrence-workflows.md index d97c21df..034100d4 100644 --- a/docs/occurrence-workflows.md +++ b/docs/occurrence-workflows.md @@ -37,3 +37,11 @@ Visibility is an independent view setting for exact placements. Hide a path and 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. Existing 3D sketch wire coordinate behavior is copied unchanged. From c1d819f235a3c5ae002e2882ae7fdb1954c561e7 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sun, 11 Oct 2026 03:59:12 -0400 Subject: [PATCH 4/5] feat: preserve body history and placed modelling workflows Record ownership transfers in the timeline so earlier Join/Cut membership, world placement, machining data and styles survive component moves, edits, independent copying, undo and reopening. Preserve pending positions in history snapshots and keep constructor transfers ordered during feature redefinition. Retain shallow sheet pockets and exact planar intersection islands through unfold/refold, with depth-aware flat previews and DXF export. Store explicit 3D wire coordinate spaces and migrate known legacy provenance without moving saved points or analytic fits. Infer unique eligible cylindrical Emboss targets, align Revolve manipulators with actual extent geometry, and discard obsolete preview errors after input correction. Certify constructed and immutable imported face styles using exact source metadata while preserving legacy face names. Upgrade designs to format10 with bounded validation and geometry-preserving legacy migration. --- Cargo.lock | 66 ++ Cargo.toml | 1 + README.md | 7 +- ROADMAP.md | 29 +- THIRD-PARTY-LICENSES.txt | 631 +++++++++++++++++- crates/doc/Cargo.toml | 1 + crates/doc/src/appearance.rs | 11 +- crates/doc/src/assembly.rs | 7 +- crates/doc/src/body_transfer.rs | 80 +++ crates/doc/src/body_transfer_sheet.rs | 54 ++ crates/doc/src/clipboard.rs | 3 +- crates/doc/src/config.rs | 4 + crates/doc/src/config_certificates.rs | 232 +++++++ crates/doc/src/config_clone.rs | 5 + crates/doc/src/config_styles.rs | 91 ++- crates/doc/src/document.rs | 60 +- crates/doc/src/eval.rs | 318 +++++++-- crates/doc/src/eval_more.rs | 160 +++-- crates/doc/src/format.rs | 237 ++++++- crates/doc/src/joints.rs | 2 +- crates/doc/src/lib.rs | 7 +- crates/doc/src/params.rs | 3 +- crates/doc/src/sheet.rs | 215 +++++- crates/engine/src/cmd/browser.rs | 2 +- crates/engine/src/cmd/component.rs | 68 +- crates/engine/src/cmd/component_clone.rs | 51 +- .../engine/src/cmd/component_clone_tests.rs | 74 ++ crates/engine/src/cmd/component_properties.rs | 6 +- crates/engine/src/cmd/component_transfer.rs | 153 +++++ .../src/cmd/component_transfer_tests.rs | 287 ++++++++ crates/engine/src/cmd/config.rs | 28 +- .../src/cmd/config_certificate_tests.rs | 251 +++++++ crates/engine/src/cmd/config_style_tests.rs | 11 +- crates/engine/src/cmd/contact_sweep.rs | 4 +- crates/engine/src/cmd/edit.rs | 53 +- crates/engine/src/cmd/edit_tests.rs | 44 ++ crates/engine/src/cmd/features.rs | 6 +- crates/engine/src/cmd/features_more.rs | 142 +++- crates/engine/src/cmd/features_more_tests.rs | 165 ++++- crates/engine/src/cmd/inspect.rs | 2 +- crates/engine/src/cmd/joints.rs | 2 +- crates/engine/src/cmd/motion.rs | 4 +- crates/engine/src/cmd/occurrence_tests.rs | 7 +- crates/engine/src/cmd/parts.rs | 2 +- crates/engine/src/cmd/sheet.rs | 61 +- crates/engine/src/cmd/sheet_tests.rs | 357 ++++++++++ crates/engine/src/cmd/sketch_3d.rs | 92 ++- .../engine/src/cmd/sketch_3d_frame_tests.rs | 312 +++++++++ crates/engine/src/cmd/sketch_project.rs | 32 +- crates/engine/src/lib.rs | 25 +- crates/engine/src/occurrences.rs | 2 +- crates/engine/src/tests.rs | 31 +- crates/kernel/src/body.rs | 62 ++ crates/kernel/src/build.rs | 42 +- crates/kernel/src/emboss.rs | 295 +++++++- crates/kernel/src/face_source_tests.rs | 75 +++ crates/kernel/src/lib.rs | 6 +- crates/kernel/src/meshbody.rs | 2 + crates/kernel/src/ops.rs | 3 +- crates/kernel/src/prism.rs | 76 ++- crates/kernel/src/splitface.rs | 10 +- crates/kernel/src/step_in/mod.rs | 20 +- crates/kernel/src/surfaces.rs | 2 + crates/sketch/src/lib.rs | 2 +- crates/sketch/src/wire.rs | 199 +++++- crates/ui-egui/src/browser.rs | 2 +- crates/ui-egui/src/canvas.rs | 324 +++++++-- crates/ui-egui/src/context_menu.rs | 9 +- crates/ui-egui/src/dialogs.rs | 29 +- crates/ui-egui/src/dialogs_assembly.rs | 2 +- crates/ui-egui/src/dialogs_motion.rs | 66 +- crates/ui-egui/src/dialogs_sheet.rs | 58 +- crates/ui-egui/src/dialogs_sheet_tests.rs | 124 ++++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menu_tests.rs | 25 +- crates/ui-egui/src/occurrence_ui_tests.rs | 22 + crates/ui-egui/src/preselect_tests.rs | 52 ++ crates/ui-egui/src/preview.rs | 72 +- crates/ui-egui/src/revolve_edit_tests.rs | 6 + .../ui-egui/src/revolve_manipulator_tests.rs | 272 ++++++++ crates/ui-egui/src/sketch3d_frame_ui_tests.rs | 67 ++ docs/book/src/guide/files.md | 12 +- docs/book/src/guide/sheet-metal.md | 15 + docs/book/src/guide/sketching.md | 12 + docs/configurations.md | 24 +- docs/occurrence-workflows.md | 10 +- 86 files changed, 6011 insertions(+), 488 deletions(-) create mode 100644 crates/doc/src/body_transfer.rs create mode 100644 crates/doc/src/body_transfer_sheet.rs create mode 100644 crates/doc/src/config_certificates.rs create mode 100644 crates/engine/src/cmd/component_transfer.rs create mode 100644 crates/engine/src/cmd/component_transfer_tests.rs create mode 100644 crates/engine/src/cmd/config_certificate_tests.rs create mode 100644 crates/engine/src/cmd/sketch_3d_frame_tests.rs create mode 100644 crates/kernel/src/face_source_tests.rs create mode 100644 crates/ui-egui/src/revolve_manipulator_tests.rs create mode 100644 crates/ui-egui/src/sketch3d_frame_ui_tests.rs 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 2d25820d..2ed7066a 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,12 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). - **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. + 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, - exact line/arc/circle DXF outlines and bend tables; plastic enclosure features and rules. + 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 5b1565d0..208d9f77 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: 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. Remaining workflow repairs: mixed offset junctions, periodic closed spline seams, and consistent occurrence-frame 3D wire authoring | in progress | 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, periodic closed 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); 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; 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. Left: general curved-face emboss, automatic cylinder target inference, exact (unfaceted) modelled threads | in progress | 12 | +| 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. 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). 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. Left: certified sketch-side/imported face lineage and 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/9"`, 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/10"`, 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 @@ -116,10 +116,13 @@ headline counts in-scope tabs only and lists the deferred ones separately. 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 rotator direction and To Object value interaction remain follow-on usability repairs. + 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; correcting that saved behavior needs versioned semantics. - 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. Select the face explicitly and capture pending positions first. Saved target + 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. @@ -148,9 +151,11 @@ headline counts in-scope tabs only and lists the deferred ones separately. 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. Creating a component from a body can change its historical - implicit Join membership; preserving that material through ownership transfers remains open. - Legacy 3D wire authoring needs further work. See [occurrence workflows](docs/occurrence-workflows.md). + 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 @@ -161,8 +166,10 @@ headline counts in-scope tabs only and lists the deferred ones separately. 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. - Partial-depth Cut and Intersect still lack retained machining metadata; later sheet rebuilds - can restore removed material. Their continuity repair remains open. + 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 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/appearance.rs b/crates/doc/src/appearance.rs index 7fefc281..12a577d1 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -48,6 +48,8 @@ pub struct FaceLook { /// 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 { @@ -139,7 +141,13 @@ pub fn material_look(material: &str) -> Option { impl Document { /// Shared definition appearance for a computed occurrence body. pub fn model_body_look(&self, body: &crate::ModelBody) -> Option { - self.body_look(body.definition_name(), body.feature) + 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 { @@ -198,6 +206,7 @@ impl Document { 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 { diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index ece7c690..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); @@ -392,9 +393,9 @@ impl Document { 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() { - for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) { + 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.body_offsets.get(&b.name) { + let w = match self.model_body_offset(b) { Some(o) => mat_mul(&m, o), None => m, }; 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 add9db0a..d0b89bfd 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -304,7 +304,8 @@ impl crate::Feature { | K::Align { bodies: list, .. } | K::Remove { bodies: list } | K::Stitch { bodies: list, .. } - | K::Move { bodies: list, .. } => { + | K::Move { bodies: list, .. } + | K::BodyTransfer { bodies: list, .. } => { for n in list { mapped_name(n, bodies); } diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs index e0e2bc47..db242353 100644 --- a/crates/doc/src/config.rs +++ b/crates/doc/src/config.rs @@ -12,6 +12,9 @@ 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::*; @@ -235,6 +238,7 @@ impl Document { look, name: Some(target.name.clone()), strict_name: true, + certificate: target.certificate.clone(), }); } } diff --git a/crates/doc/src/config_certificates.rs b/crates/doc/src/config_certificates.rs new file mode 100644 index 00000000..4b51cadf --- /dev/null +++ b/crates/doc/src/config_certificates.rs @@ -0,0 +1,232 @@ +//! 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; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum FaceCertificate { + 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 + && let Some(mapped) = features.get(sketch) + { + *sketch = *mapped; + } + } + pub fn validate(&self) -> Result<()> { + let valid = match self { + 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("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, .. }) => *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 Err(DocError::Invalid("face source does not match producer".into())), + }; + certificate.validate()?; + Ok(Some(certificate)) + } +} + +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 index 7ccb8eba..da1aac92 100644 --- a/crates/doc/src/config_clone.rs +++ b/crates/doc/src/config_clone.rs @@ -75,6 +75,9 @@ impl ConfigTable { .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) }) } @@ -140,6 +143,7 @@ mod tests { origin_feature: 3, name: "F3:+z".into(), point: solvecraft_geom::Vec3::new(1., 1., 2.), + certificate: None, }), ], rows: vec![ConfigRow { @@ -196,6 +200,7 @@ mod tests { 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()] }], diff --git a/crates/doc/src/config_styles.rs b/crates/doc/src/config_styles.rs index 577e5c38..f9b4d949 100644 --- a/crates/doc/src/config_styles.rs +++ b/crates/doc/src/config_styles.rs @@ -13,6 +13,8 @@ pub struct ConfigFaceTarget { 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 { @@ -23,6 +25,9 @@ impl ConfigFaceTarget { 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() @@ -36,6 +41,25 @@ impl ConfigFaceTarget { } } +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) { @@ -44,18 +68,7 @@ pub fn validate_styles_json(value: &Value) -> Result<()> { } for column in columns { if let Some(target) = column.get("face_appearance") { - let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?; - if fields.len() != 4 - || fields.keys().any(|key| !["body", "origin_feature", "name", "point"].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())); - } + validate_face_target_json(target)?; } if let Some(id) = column.get("component_appearance") && id.as_u64().is_none_or(|id| id == 0) @@ -64,6 +77,23 @@ pub fn validate_styles_json(value: &Value) -> Result<()> { } } } + 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(()) } @@ -77,6 +107,17 @@ impl Document { 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::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), @@ -108,12 +149,38 @@ impl Document { if body.body.is_mesh() || body.definition_name() != target.body { return Err(DocError::Invalid("configured face definition does not match".into())); } + 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())); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 3ed7742a..0f16df14 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -174,8 +174,8 @@ pub struct EmbossFace { 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_feature: u64) -> Result { - let owner = doc.body_component(&self.body, body_feature); + 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); } @@ -202,10 +202,10 @@ impl EmbossFace { } /// Resolve this exact placement without expanding every assembly instance. - pub fn body_frame(&self, doc: &Document, feature: u64) -> Result { - let owner = doc.body_component(&self.body, feature); + 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.body_offsets.get(&self.body) { + if let Some(offset) = doc.model_body_offset(body) { frame = crate::mat_mul(&frame, offset); } Ok(frame) @@ -244,6 +244,14 @@ impl EmbossFace { #[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, @@ -1121,7 +1129,8 @@ impl FeatureKind { | K::SurfaceTrim { .. } | K::Move { .. } | K::Import { .. } - | K::MeshImport { .. } => Vec::new(), + | K::MeshImport { .. } + | K::BodyTransfer { .. } => Vec::new(), } } @@ -1146,6 +1155,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", @@ -1210,6 +1220,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", @@ -1465,7 +1476,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 } @@ -1701,9 +1712,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) { @@ -1784,6 +1822,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 { @@ -1796,7 +1840,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(), diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 626b7229..693fd349 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -33,6 +33,8 @@ 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>, } @@ -49,6 +51,7 @@ impl ModelBody { key: 0, names: Default::default(), component: 0, + transferred: false, imported: None, } } @@ -274,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); @@ -292,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) => { @@ -302,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()) @@ -494,6 +508,26 @@ 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()) { return 0; @@ -673,7 +707,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() } @@ -681,7 +715,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 @@ -723,7 +759,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))); @@ -794,7 +830,55 @@ fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> St } /// 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 }; @@ -820,6 +904,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(()) }; @@ -859,6 +950,8 @@ 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) { @@ -1382,8 +1475,57 @@ 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 { + 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 = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64)); + let flat = |point: Vec3| { + let v = point - origin; + v - axis * v.dot(axis) + }; + let (u, v) = (flat(centre), flat(point)); + let sweep = (-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 = if unit_axis.cross(radial).dot(plane.normal()) < 0.0 { -unit_axis } else { unit_axis }; + let display_start = start * -unit_axis.dot(rotation_axis); + 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(); @@ -1486,41 +1628,36 @@ 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() { + tools = tools + .into_iter() + .map(|body| { + let curves: Vec<_> = body + .face_sources() + .into_iter() + .flatten() + .map(|source| match source { + Some(kernel::FaceSource::ExtrudeSide { segment }) => crate::config::exact_sketch_curve(&ss, segment), + _ => None, + }) + .collect(); + body.with_extrude_certificates(f.id, *sketch, &curves).map_err(DocError::from) + }) + .collect::>>()?; + } Ok(tools) } FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, 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(st, &ss.plane, ®ions, (ss.plane.to_world(o), ss.plane.dir_to_world(d)), angles, *symmetric, *to)?; + let (start, ang) = (extent.start, extent.sweep); let (operation, targets) = feature_op(f); let axis_w = ss.plane.dir_to_world(d); let origin_w = ss.plane.to_world(o); @@ -1633,6 +1770,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. @@ -1645,6 +1791,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())); @@ -1709,6 +1857,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. @@ -1890,10 +2083,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) { @@ -1928,6 +2121,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(); @@ -2093,7 +2288,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)?); @@ -2348,6 +2543,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 { .. } @@ -2405,9 +2601,9 @@ 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 }; @@ -2436,7 +2632,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. @@ -2637,7 +2833,7 @@ 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); } @@ -2886,10 +3082,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } 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(); @@ -2946,9 +3143,18 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram 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(); @@ -2960,6 +3166,7 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram 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(); @@ -2973,6 +3180,10 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram { 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) } @@ -3002,16 +3213,17 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { for (comp, path, m) in doc.placements() { let primary = path == first_path(comp); let ident = crate::is_identity(&m); - for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) { + 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. @@ -3027,12 +3239,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 3c8e5f73..e6d6add3 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -4,6 +4,7 @@ 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<()> { @@ -22,11 +23,11 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, 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.feature)?; + 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.feature)?; + 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), @@ -289,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(()) } @@ -513,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); @@ -528,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 { @@ -543,51 +555,123 @@ 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(); - let mut overlap_work = crate::sheet::FlatWork::new(); let targets = if targets.is_empty() { &f.participants } else { targets }; let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); - for i in 0..st.sheets.len() { - let Some(sh) = st.sheets.get(i).cloned() else { continue }; + 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 == sh.body && (b.component == f.component || b.feature == f.id)) + st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id)) } else { - targets.contains(&sh.body) + 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 { sh.flat_regions()?.into_iter().map(|region| (None, region)).collect() } - } 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; - } - for r in ®ions { - let flat = sh.region_to_flat(pi, &ss.plane, r)?; - let overlaps = crate::sheet::flat_regions_overlap(&panel, &flat, &mut overlap_work)?; - if overlaps && !added.iter().any(|x: &Region2| x == &flat) { - added.push(flat); + 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) } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 9017fb7e..6e9bf80b 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -28,13 +28,16 @@ //! - 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. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 9; +pub const FORMAT: u32 = 10; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -47,7 +50,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; 8] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(())]; +const JSON_STEPS: [JsonStep; 9] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| 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, @@ -142,10 +145,29 @@ pub fn read(s: &str) -> Result { crate::config::validate_styles_json(&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 @@ -157,9 +179,21 @@ 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}")))?; + } + 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. @@ -168,6 +202,112 @@ pub fn read(s: &str) -> Result { 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(()) }; @@ -335,6 +475,99 @@ 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}; 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 248942da..4e8d6557 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -7,6 +7,9 @@ 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}; @@ -29,8 +32,8 @@ 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, object_height, parse_metric_thread, - pattern_matrices, state_in_frame, state_in_frame_at, through_all_distance, world_state, + EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, + parse_metric_thread, pattern_matrices, revolve_extent, 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; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index fde2cebe..c1b54110 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -274,7 +274,8 @@ impl FeatureKind { | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::Import { .. } - | FeatureKind::MeshImport { .. } => {} + | FeatureKind::MeshImport { .. } + | FeatureKind::BodyTransfer { .. } => {} } v } diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 1d109626..eb06fe07 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -83,24 +83,32 @@ fn planar_region(region: &Region2) -> Result { } fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> { - if regions.len() > MAX_FLAT_PIECES { - return Err(flat_budget_error()); - } + regions_complexity(regions.iter()) +} + +fn regions_complexity<'a>(regions: impl Iterator) -> Result<(usize, usize)> { let (mut edges, mut points) = (0usize, 0usize); - for lp in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) { - edges = edges.saturating_add(lp.segs.len()); - if edges > MAX_FLAT_POINTS { + 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()); } - points = points.saturating_add(flat_polyline(lp)?.len()); - if points > MAX_FLAT_POINTS { - 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)) } -fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result> { +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); @@ -112,7 +120,7 @@ fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut // Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not // repeatedly arrange a growing accumulated boundary against just one small region. -fn material_union(regions: Vec, work: &mut FlatWork) -> Result> { +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 { @@ -290,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. @@ -299,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 { @@ -564,6 +583,7 @@ impl SheetBody { } cuts.push(region); } + cuts.extend(self.through_pocket_regions(work)?); let cuts = material_union(cuts, work)?; let mut out = Vec::new(); let mut points = 0usize; @@ -734,20 +754,148 @@ impl SheetBody { } let mut work = FlatWork::new(); let cells = self.cells_with_work(&mut work)?; - Ok(kernel::sew(&self.faces(&cells, 0.0, self.t, &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 regions = self.flat_regions()?; let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?; - Ok(kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?) + let body = kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?; + self.machine_pockets(body, &mut FlatWork::new()) + } + + /// 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()))?; + } + } + 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. @@ -783,11 +931,9 @@ impl SheetBody { } 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)? }; - if regions.is_empty() { - return Err(DocError::Invalid("empty flat pattern".into())); - } Ok(regions) } /// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out. @@ -1332,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 { @@ -1465,6 +1627,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region base: Region2 { outer: region.outer, holes: Vec::new() }, flanges: Vec::new(), holes, + pockets: Vec::new(), flat: false, gap: rv.gap, base_curves: Vec::new(), @@ -1516,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(), @@ -1780,4 +1944,23 @@ mod tests { 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/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 13da3a96..36a4f6ec 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -235,7 +235,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result { } } let mut root_bodies: Vec = - s.model.state().bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == 0).map(|b| b.name.clone()).collect(); + 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; diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index cbeaabd7..79616264 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -13,6 +13,8 @@ use crate::{Result, Session}; #[path = "component_properties.rs"] mod properties; +#[path = "component_transfer.rs"] +mod transfer; #[cfg(test)] #[path = "occurrence_tests.rs"] @@ -133,25 +135,7 @@ 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 { @@ -176,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() @@ -223,6 +207,15 @@ 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 { @@ -241,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 { diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index 5acf8d5d..08f3065c 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -232,20 +232,29 @@ pub(super) fn paste( } // 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.body_component(&b.name, b.feature)) && !owned_features.contains(&b.feature)) - { + 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 comp = source.body_component(&body.name, body.feature); - let offset = source.body_offsets.get(&body.name).copied().unwrap_or(solvecraft_doc::IDENTITY); + 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 base = before.as_ref().and_then(|st| st.body(&body.name)).unwrap_or(body); + 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); @@ -256,7 +265,7 @@ pub(super) fn paste( Some(&feature_name), )?; if let Some(f) = scratch.doc_mut().feature_mut(id) { - f.component = *components.get(&comp).ok_or_else(|| bad(CMD, "missing assigned component"))?; + 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); @@ -316,6 +325,15 @@ pub(super) fn paste( 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 @@ -361,7 +379,7 @@ pub(super) fn paste( let moved_out: Vec<_> = state .bodies .iter() - .filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.body_component(&b.name, b.feature))) + .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() { @@ -376,17 +394,24 @@ pub(super) fn paste( 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"))?; } - face.point = solvecraft_doc::apply_point(source.body_offsets.get(&face.body).unwrap_or(&solvecraft_doc::IDENTITY), face.point); + 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); } } @@ -519,9 +544,9 @@ pub(super) fn paste( 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.body_component(&b.name, b.feature))).count(); + 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.body_component(&b.name, b.feature))).count(); + 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")); } @@ -533,11 +558,11 @@ pub(super) fn paste( 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.body_component(&b.name, b.feature))) { + 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.body_offsets.get(&body.name).unwrap_or(&solvecraft_doc::IDENTITY))?; - let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.body_offsets.get(new).unwrap_or(&solvecraft_doc::IDENTITY))?; + 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()); diff --git a/crates/engine/src/cmd/component_clone_tests.rs b/crates/engine/src/cmd/component_clone_tests.rs index 1273710e..b0c59425 100644 --- a/crates/engine/src/cmd/component_clone_tests.rs +++ b/crates/engine/src/cmd/component_clone_tests.rs @@ -238,6 +238,7 @@ fn configuration_rows_follow_copied_parameters_material_and_strict_face_lineage( 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())); @@ -275,6 +276,7 @@ fn configured_extrude_cap_copy_keeps_independent_semantic_lineage() { 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)); @@ -391,3 +393,75 @@ fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() { 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 index c642e85c..195d565e 100644 --- a/crates/engine/src/cmd/component_properties.rs +++ b/crates/engine/src/cmd/component_properties.rs @@ -26,7 +26,7 @@ fn placed_doc(s: &Session) -> Document { /// A property total must cover the entire assembly. The display traversal intentionally /// truncates hostile designs, so use an explicit error instead of accepting its prefix. -fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> { +pub(super) fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> { let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)]; let mut stack = vec![(0, Vec::::new(), solvecraft_doc::IDENTITY)]; while let Some((component, path, transform)) = stack.pop() { @@ -112,8 +112,8 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, }; for (component, path, transform) in selected_placements { placement_count += 1; - for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) { - let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform); + 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); 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 7e61db30..d5335c98 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -63,6 +63,15 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { .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(); @@ -71,20 +80,7 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { 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 names = solvecraft_doc::naming::face_names(body); - let name = names.get(index).map(|name| solvecraft_doc::naming::strip_piece(name).to_string()).ok_or_else(|| bad(cmd, "face name missing"))?; - let origin_feature = name - .strip_prefix('F') - .and_then(|name| name.split_once(':')) - .and_then(|(id, _)| id.parse::().ok()) - .ok_or_else(|| bad(cmd, "face lineage missing"))?; - let inverse = solvecraft_doc::mat_inverse(&body.source_transform).ok_or_else(|| bad(cmd, "invalid definition placement transform"))?; - let target = solvecraft_doc::config::ConfigFaceTarget { - body: body.definition_name().into(), - origin_feature, - name, - point: solvecraft_doc::apply_point(&inverse, 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)); @@ -415,6 +411,10 @@ fn preview(s: &mut Session, p: &Value) -> Result { 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)] 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 index 2580ea1d..d81c0591 100644 --- a/crates/engine/src/cmd/config_style_tests.rs +++ b/crates/engine/src/cmd/config_style_tests.rs @@ -133,6 +133,7 @@ fn strict_face_missing_lineage_never_retargets_and_rolls_back() { 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()); @@ -175,7 +176,7 @@ fn extrude_cap_certification_follows_actual_positive_negative_and_symmetric_name 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 }; + 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(); @@ -211,7 +212,13 @@ 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 }; + 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]}), diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs index 45fc941c..21955181 100644 --- a/crates/engine/src/cmd/contact_sweep.rs +++ b/crates/engine/src/cmd/contact_sweep.rs @@ -65,7 +65,7 @@ impl ContactGeometry { if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) { continue; } - for body in state.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == component) { + 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")); } @@ -113,7 +113,7 @@ impl ContactGeometry { }; out.bodies.push(CachedBody { path: path.clone(), - offset: s.doc.body_offsets.get(&body.name).copied().unwrap_or(IDENTITY), + offset: s.doc.model_body_offset(body).copied().unwrap_or(IDENTITY), mesh, size: body.body.size(), }); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 89497f14..e19dc52b 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 { @@ -360,7 +366,13 @@ fn redefine(s: &mut Session, p: &Value) -> Result { } trial.active_sketch = None; trial.selection = s.selection.clone(); - trial.doc_mut().marker = Some(idx); + // 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)?; @@ -399,6 +411,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result { 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; @@ -414,6 +427,40 @@ fn redefine(s: &mut Session, p: &Value) -> Result { 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()) { diff --git a/crates/engine/src/cmd/edit_tests.rs b/crates/engine/src/cmd/edit_tests.rs index bb289609..1d1dd86e 100644 --- a/crates/engine/src/cmd/edit_tests.rs +++ b/crates/engine/src/cmd/edit_tests.rs @@ -170,11 +170,31 @@ fn redefine_keeps_allocator_above_component_and_occurrence_ids() { 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); @@ -186,6 +206,30 @@ fn redefine_keeps_allocator_above_component_and_occurrence_ids() { 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(); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 88082a8f..9a15b4f5 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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"); @@ -324,7 +324,7 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu 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.feature).map_err(|e| EngineError::Other(e.to_string()))?; + 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(); @@ -727,7 +727,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) diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index c7ebedc6..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, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (required for wrapped orthogonal line profiles)"), + .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") @@ -192,50 +192,120 @@ fn emboss(s: &mut Session, p: &Value) -> Result { // 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 original = s.model.state(); - let owner = s.doc.body_component(name, selected.feature); - 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"))?; - if path.len() > 64 { - return Err(bad(cmd, "selected cylinder placement is too deep")); - } - let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component)); - if author_path.len() > 64 { - return Err(bad(cmd, "author placement is too deep")); - } - 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 { - path.clone() - } else if sketch_owner == s.active_component { - author_path.clone() - } else { - s.doc.first_path(sketch_owner) - }; - if path.iter().chain(&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")); - } - let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?; - let at = solvecraft_doc::apply_point(&inverse, at); + let face = emboss_selection(s, selected, at)?; + check_emboss_positions(s, sketch, selected, &face)?; targets = vec![name.to_owned()]; - Some(solvecraft_doc::EmbossFace { body: name.to_owned(), at, path, author_path }) + Some(face) } _ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")), } } else { - None + 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 c4b9bb4c..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!({})) } @@ -169,7 +180,8 @@ fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() { #[test] fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() { - for pick_offset in [0., 80.] { + 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"})); @@ -255,6 +267,11 @@ fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() { #[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"})); @@ -279,7 +296,7 @@ fn cylinder_active_second_source_occurrence_authors_shared_geometry() { #[test] fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() { - for ancestor in [false, true] { + 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(); @@ -291,7 +308,10 @@ fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() { let before = s.doc.to_json(); let pending = s.pending_moves.clone(); let volume = exact_cylinder_volume(&s); - let error = s.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1})).unwrap_err().to_string(); + 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); @@ -301,6 +321,10 @@ fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() { #[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(); @@ -314,7 +338,10 @@ fn cylinder_pending_third_component_profile_requires_capture_atomically() { let pending = s.pending_moves.clone(); let volume = exact_cylinder_volume(&s); let error = s - .execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})) + .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}"); @@ -325,12 +352,21 @@ fn cylinder_pending_third_component_profile_requires_capture_atomically() { #[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]})); - run(&mut s, "component.move_bodies", json!({"bodies":["Sleeve"],"component":holder})); + 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"})); @@ -399,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 23d46a18..ca4250b4 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -401,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, strict_name: false }); + a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false, certificate: None }); } } for c in &comps { 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/motion.rs b/crates/engine/src/cmd/motion.rs index 42bb6b66..33b6fed5 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -89,11 +89,11 @@ 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()) { + 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.body_offsets.get(&b.name).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m); + 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)); } diff --git a/crates/engine/src/cmd/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs index 7a6f0905..515c9cb9 100644 --- a/crates/engine/src/cmd/occurrence_tests.rs +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -305,7 +305,12 @@ fn hostile_moves_copies_and_reused_ids_do_not_leak_state() { run(&mut s, "component.capture_position", json!({})); assert!(s.pending_moves.is_empty()); run(&mut s, "edit.undo", json!({})); - assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0); + 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] 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/sheet.rs b/crates/engine/src/cmd/sheet.rs index d04d2fe0..443a0a51 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -293,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}), ) } @@ -305,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"); @@ -316,8 +348,14 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { w(2, "TABLES"); w(0, "TABLE"); w(2, "LAYER"); - w(70, "3"); - for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] { + w(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"); @@ -337,8 +375,13 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { w(11, &f(b.x)); w(21, &f(b.y)); }; - let regions = sh.flat_regions()?; - for h in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) { + 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, 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() { @@ -346,7 +389,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { 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, "OUTLINE"); + w(8, &layer); w(10, &f(center.x)); w(20, &f(center.y)); w(40, &f(*radius)); @@ -358,7 +401,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => { if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 { w(0, "CIRCLE"); - w(8, "OUTLINE"); + w(8, &layer); w(10, &f(center.x)); w(20, &f(center.y)); w(40, &f(radius)); @@ -366,7 +409,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { } 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)); @@ -376,7 +419,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { other => { 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]); } } } diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 524eff6d..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(); diff --git a/crates/engine/src/cmd/sketch_3d.rs b/crates/engine/src/cmd/sketch_3d.rs index 061ff353..d2a243c0 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}; @@ -35,32 +36,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})) } @@ -68,23 +108,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_project.rs b/crates/engine/src/cmd/sketch_project.rs index b7ee7586..11fe99ce 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -123,27 +123,49 @@ 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 st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp)); + 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 st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, 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()) } else { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 86d8a4b8..e371eb15 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -133,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 @@ -184,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, } @@ -332,6 +334,13 @@ impl Session { ) { 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); + } + } + } } } } @@ -362,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 @@ -394,9 +408,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) @@ -404,7 +419,7 @@ impl Session { } } - /// 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; @@ -413,6 +428,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 { @@ -428,6 +444,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(); diff --git a/crates/engine/src/occurrences.rs b/crates/engine/src/occurrences.rs index 136a158d..70a59c3d 100644 --- a/crates/engine/src/occurrences.rs +++ b/crates/engine/src/occurrences.rs @@ -79,7 +79,7 @@ impl Session { let bodies = state .bodies .iter() - .filter(|b| self.doc.body_component(&b.name, b.feature) == component) + .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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e156d671..9e2529ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -864,7 +864,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 +992,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 +1017,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(); diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index eafac71e..cbe92ed2 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -27,6 +27,15 @@ 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 }, + CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String }, + Imported { item: u64, face: u64, payload_sha256: Option, producer: Option }, } /// How a body looks beyond its colour, for export: its opacity and its faces' own colours. @@ -287,6 +296,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 { @@ -324,9 +334,61 @@ 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) + } + + /// 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 { .. }), Some(curve)) => { + Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone() }) + } + (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) diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index f6fc3ba6..03899b3d 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). @@ -95,9 +103,35 @@ 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 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() { + None + } else if let [segment] = matching.as_slice() { + Some(crate::FaceSource::ExtrudeSide { segment: **segment }) + } else { + None + }; + result.push(source); + } + Ok(result) + })?; + out.push(body.with_face_sources(Some(std::sync::Arc::new(provenance)))); } Ok(out) } diff --git a/crates/kernel/src/emboss.rs b/crates/kernel/src/emboss.rs index 9d0c337f..806af16f 100644 --- a/crates/kernel/src/emboss.rs +++ b/crates/kernel/src/emboss.rs @@ -17,6 +17,21 @@ 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 { @@ -40,7 +55,7 @@ pub fn cylinder_emboss_anchor(body: &Body, face: usize, at: Vec3) -> Result Result { - emboss_impl(body, None, plane, regions, depth) + emboss_impl(body, None, plane, regions, depth, false).and_then(EmbossOutcome::body) } /// As [`emboss_cylinder`], requiring the saved selected point to identify that wall. @@ -48,10 +63,101 @@ pub fn emboss_cylinder_at(body: &Body, at: Vec3, plane: &Plane, regions: &[Regio if !at.is_finite() || at.len() > 1e9 { return Err(fail("invalid selected face point")); } - emboss_impl(body, Some(at), plane, regions, depth) + emboss_impl(body, Some(at), plane, regions, depth, false).and_then(EmbossOutcome::body) } -fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Region2], depth: f64) -> Result { +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")); @@ -77,8 +183,8 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi 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 = w - a * w.dot(a); - if (radial.len() - r).abs() > tol * 10.0 || plane.normal().dot(radial / r) < 1.0 - 1e-8 { + 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"))?; @@ -87,17 +193,9 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi if f.boundaries().len() != 1 || f.edge_iter().count() != 4 { continue; } - let mut angular = None; - for e in f.edge_iter() { - if let Some(Some(mid)) = crate::offset::arc_mid(&e, tol * 10.0) { - let am = angle(mid, o, a, radial, tangent); - let aa = unwrap(angle(from_p3(e.front().point()), o, a, radial, tangent), am); - let ab = unwrap(angle(from_p3(e.back().point()), o, a, radial, tangent), am); - angular = Some((aa.min(ab), aa.max(ab))); - break; - } - } - let Some((alo, ahi)) = angular else { continue }; + let Some((alo, ahi)) = wall_angles(f, o, a, radial, tangent, tol) else { + continue; + }; if alo >= -tol / r || ahi <= tol / r { continue; } @@ -108,6 +206,9 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi } 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 { @@ -131,6 +232,12 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi } 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")); @@ -144,14 +251,21 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi } } let z0 = (plane.origin - o).dot(axis); - let map2 = |p: Vec2| Vec2::new(plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).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 || lp.segs.len() < 4 || !lp.is_closed(tol) { + 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(); @@ -181,6 +295,12 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi 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()]; @@ -252,10 +372,108 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi if !result.validity().is_empty() { return Err(fail("rebuilt cylinder is not a closed manifold")); } - Ok(result) + 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(); @@ -346,6 +564,45 @@ mod tests { 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(); diff --git a/crates/kernel/src/face_source_tests.rs b/crates/kernel/src/face_source_tests.rs new file mode 100644 index 00000000..8968b2b5 --- /dev/null +++ b/crates/kernel/src/face_source_tests.rs @@ -0,0 +1,75 @@ +use crate::{FaceSource, extrude, transform_matrix}; +use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +#[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| source.is_none()).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(); + assert_eq!(placed.face_sources(), source.face_sources()); + 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 a464e526..00a48f02 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -21,6 +21,8 @@ mod coplanar; mod curveblend; mod delete_face; mod emboss; +#[cfg(test)] +mod face_source_tests; mod freeform; pub mod fuzz; mod heal; @@ -52,10 +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 body::{Body, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; pub use build::{PathSeg, box_solid, 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}; +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; 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 105e9f15..a3125ae7 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -531,7 +531,8 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result { m[3][3], ); // An affine placement retains face order and appearance metadata. - Body::new(placed_topology(&body.solid, mat)?).map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned())) + Body::new(placed_topology(&body.solid, mat)?) + .map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(body.face_sources.clone())) }) } diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index a6e1f280..a6c19847 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -862,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] { @@ -882,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) { @@ -923,6 +945,42 @@ mod planar_tests { 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)); 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_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/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/lib.rs b/crates/sketch/src/lib.rs index 07c3ddcd..486faa93 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -33,7 +33,7 @@ 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/wire.rs b/crates/sketch/src/wire.rs index ab0fb296..94a5f925 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,7 +189,8 @@ 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); } @@ -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/browser.rs b/crates/ui-egui/src/browser.rs index e4ecaf35..9ef7bd98 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -1120,7 +1120,7 @@ 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), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a4ab9738..f82ae28e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -234,6 +234,12 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, 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()) @@ -285,11 +291,6 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, }); 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; let baseline = to_span.map(f64::abs).unwrap_or(0.0); @@ -464,56 +465,295 @@ pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> { 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 = -direction.dot(extent.rotation_axis); + 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_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( + &state, + &plane, + ®ions, + axis, + angles, + d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"), + to, + ) + .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/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 4bfa024b..e22b126d 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -248,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. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd8afda..a31fd6ad 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1054,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) }; @@ -1069,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() { @@ -1600,7 +1614,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); ui.label(""); ui.add( - egui::Label::new("Pick a cylinder wall to wrap the profiles around it. Leave Target Face empty for planar embossing.").wrap(), + egui::Label::new( + "Orthogonal line profiles tangent to one supported cylinder wrap automatically. Pick a wall to choose a target.", + ) + .wrap(), ); ui.end_row(); } @@ -2259,7 +2276,7 @@ pub(crate) fn emboss_profile_plane(app: &SolveApp, sketch: u64) -> Result