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