From 07932d0f42d65097f583aa69df496b59a54fd908 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sun, 11 Oct 2026 01:28:01 -0400 Subject: [PATCH] feat: preserve modeling intent across sketches, assemblies, and fabrication Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands. Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected. --- ROADMAP.md | 35 +- crates/doc/src/appearance.rs | 8 +- crates/doc/src/assembly.rs | 49 +- crates/doc/src/config.rs | 150 +++- crates/doc/src/config_rules.rs | 646 ++++++++++++++++++ crates/doc/src/document.rs | 35 +- crates/doc/src/eval.rs | 59 +- crates/doc/src/eval_more.rs | 35 +- crates/doc/src/format.rs | 68 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/params.rs | 74 ++ crates/doc/src/sheet.rs | 518 ++++++++++---- crates/engine/src/cmd/browser.rs | 70 +- crates/engine/src/cmd/component.rs | 233 ++++++- crates/engine/src/cmd/component_properties.rs | 39 +- crates/engine/src/cmd/config.rs | 281 +++++++- crates/engine/src/cmd/delete.rs | 9 +- crates/engine/src/cmd/edit.rs | 3 + crates/engine/src/cmd/inspect.rs | 15 +- crates/engine/src/cmd/occurrence_tests.rs | 421 ++++++++++++ crates/engine/src/cmd/sheet.rs | 35 +- crates/engine/src/cmd/sheet_tests.rs | 325 +++++++++ crates/engine/src/cmd/sketch.rs | 38 +- crates/engine/src/cmd/sketch_constraints.rs | 5 +- crates/engine/src/cmd/sketch_dimensioned.rs | 114 ++++ .../src/cmd/sketch_dimensioned_tests.rs | 167 +++++ crates/engine/src/frames.rs | 38 +- crates/engine/src/lib.rs | 39 +- crates/engine/src/occurrences.rs | 216 ++++++ crates/engine/src/view.rs | 15 +- crates/kernel/src/body.rs | 103 ++- crates/kernel/src/body_lump_tests.rs | 224 ++++++ crates/kernel/src/lib.rs | 1 + crates/kernel/src/ops.rs | 50 +- crates/kernel/src/prism.rs | 306 ++++++++- crates/kernel/src/sew.rs | 134 +++- crates/kernel/src/shell_groups.rs | 222 ++++++ crates/sketch/src/dims.rs | 20 + crates/sketch/src/model.rs | 77 ++- crates/sketch/src/solver.rs | 128 +++- crates/ui-egui/src/access.rs | 2 +- crates/ui-egui/src/browser.rs | 230 ++++++- crates/ui-egui/src/configuration_rules_ui.rs | 356 ++++++++++ crates/ui-egui/src/context_menu.rs | 104 ++- crates/ui-egui/src/control.rs | 11 + crates/ui-egui/src/delete.rs | 18 +- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs_appearance.rs | 4 +- crates/ui-egui/src/dialogs_motion.rs | 114 +++- crates/ui-egui/src/documents.rs | 2 + crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/menu_tests.rs | 20 + crates/ui-egui/src/occurrence_ui_tests.rs | 182 +++++ crates/ui-egui/src/sketch_dims.rs | 87 ++- crates/ui-egui/src/sketch_tools.rs | 1 + .../ui-egui/src/sketch_typed_intent_tests.rs | 184 +++++ crates/ui-egui/src/tools.rs | 93 ++- crates/ui-egui/src/viewport.rs | 42 +- .../s34_component_move_carries_sketches.json | 4 +- .../scenarios/s61_occurrence_browser.json | 22 + docs/book/src/guide/sheet-metal.md | 10 + docs/configurations.md | 62 +- docs/occurrence-workflows.md | 39 ++ docs/parity.md | 22 +- xtask/data/fusion-ids.tsv | 1 + 65 files changed, 6145 insertions(+), 484 deletions(-) create mode 100644 crates/doc/src/config_rules.rs create mode 100644 crates/engine/src/cmd/occurrence_tests.rs create mode 100644 crates/engine/src/cmd/sketch_dimensioned.rs create mode 100644 crates/engine/src/cmd/sketch_dimensioned_tests.rs create mode 100644 crates/engine/src/occurrences.rs create mode 100644 crates/kernel/src/body_lump_tests.rs create mode 100644 crates/kernel/src/shell_groups.rs create mode 100644 crates/ui-egui/src/configuration_rules_ui.rs create mode 100644 crates/ui-egui/src/occurrence_ui_tests.rs create mode 100644 crates/ui-egui/src/sketch_typed_intent_tests.rs create mode 100644 crates/ui-egui/tests/scenarios/s61_occurrence_browser.json create mode 100644 docs/occurrence-workflows.md diff --git a/ROADMAP.md b/ROADMAP.md index bed9e2f7..a54854aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,9 +7,9 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat | Question | Answer | |---|---| -| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. | -| Command parity (in scope) | **331 / 541 (61%)** in-scope commands; SOLID + SKETCH **186 / 291 (64%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity | -| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) | +| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. | +| Command parity (in scope) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity | +| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. | | Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green | | Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) | @@ -46,7 +46,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. | # | Milestone | Status | Hours left | |---|---|---|---| | M0 | Vertical slice | done | — | -| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 | +| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Remaining workflow repairs: exact free-form Extend intersections, associative free-form Offset, and consistent occurrence-frame 3D wire authoring | in progress | 4 | | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 | @@ -58,8 +58,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | -| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Left: configuration rules and component/face style columns | in progress | 4 | -| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/7"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Left: component/face style columns | in progress | 4 | +| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/8"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | ## Known limitations @@ -84,7 +84,10 @@ headline counts in-scope tabs only and lists the deferred ones separately. sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face (a part and its mirror image) are stitched; coincident planar faces are pushed apart when their neighbours are planes or cylinders. Other coincident curved configurations can still - fail. Fully internal voids are not supported. Spheres are built from six pole-free patches. + fail. Bounded containment classification preserves fully internal cavities and nested + material islands independently of shell order, with STEP round-trip and material-membership + regressions. Near-contact curved shells use the general boolean path rather than a + containment shortcut. Spheres are built from six pole-free patches. - Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles that cut through curved faces (trimmed faces after booleans) are split onto the surface first. - STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass @@ -117,6 +120,24 @@ headline counts in-scope tabs only and lists the deferred ones separately. - Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes, selecting visited values for replacement. Expression completion retains forward-Tab priority; real keyboard regressions cover rectangles, lines and single-value circles. + Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints + and expressions through save/reopen. Shape creation and its dimensions form one undo step; + rejected values keep the editable field focused. +- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use + checked occurrence paths. Active nested occurrences author planar sketches and solids in + their selected frame. Pending moves compose through parents; dialog cancellation restores + its selected move. Source materials and body/face appearances follow transformed instances. + Shared nested definitions remain shared, and legacy 3D wire authoring and deep Paste New + need further work. See [occurrence workflows](docs/occurrence-workflows.md). +- Configuration rules: ordered typed conditions can drive parameters and suppressions or + restrict values. Preview explains effective values without modifying the document; applying + rules validates rows atomically. Length expressions retain their authoring units across + document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering. + Component and face style columns remain open. See [configurations](docs/configurations.md). +- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained + islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material + pieces survive unfold/refold as one logical sheet group, including split flanged sheets. + Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling. - Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index f5f8b431..89fcfce7 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -130,6 +130,10 @@ pub fn material_look(material: &str) -> Option { } impl Document { + /// Shared definition appearance for a computed occurrence body. + pub fn model_body_look(&self, body: &crate::ModelBody) -> Option { + self.body_look(body.definition_name(), body.feature) + } /// A body's look (without face appearances): its own, else its component's, else its /// material's. pub fn body_look(&self, name: &str, feature: u64) -> Option { @@ -167,8 +171,8 @@ impl Document { None => Vec::new(), }, }; - for f in self.appearances.faces.iter().filter(|f| f.body == b.name) { - let (p, look) = (f.point, f.look.clone()); + for f in self.appearances.faces.iter().filter(|f| f.body == b.definition_name()) { + let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone()); // By name (every piece of a split face), else by the point. if let Some(n) = &f.name { let stem = crate::naming::strip_piece(n); diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 771d31a3..ece7c690 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -385,24 +385,61 @@ impl Document { /// further instance), its component, the occurrence path, and the transform from the frame /// its geometry is in to the world. pub fn body_frames(&self, st: &crate::ModelState) -> Vec<(String, u64, Vec, Mat)> { + self.body_frames_with_source(st).into_iter().map(|(world, _, component, path, matrix)| (world, component, path, matrix)).collect() + } + + /// Body frames with explicit definition names; callers must not infer them from labels. + pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec, Mat)> { let mut out = Vec::new(); for (comp, path, m) in self.placements() { - let primary = path == self.first_path(comp); - let label = || -> String { - path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::>().join("/") - }; for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) { - let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) }; + let name = self.world_body_name(st, &b.name, comp, &path); let w = match self.body_offsets.get(&b.name) { Some(o) => mat_mul(&m, o), None => m, }; - out.push((name, comp, path.clone(), w)); + out.push((name, b.definition_name().to_string(), comp, path.clone(), w)); } } out } + /// Computed placement name. Source names retain priority; ambiguous labels use full IDs. + /// Resolves label ambiguity with bounded dynamic counts instead of expanding all placements. + pub fn world_body_name(&self, st: &crate::ModelState, source: &str, component: u64, path: &[u64]) -> String { + if path == self.first_path(component) { + return source.to_string(); + } + let names: Vec<&str> = path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect(); + let label = names.join("/"); + let candidate = format!("{source} ({label})"); + let mut counts = std::collections::BTreeMap::from([(0, 1usize)]); + for name in &names { + let mut next = std::collections::BTreeMap::::new(); + for o in self.occurrences.iter().filter(|o| o.name == *name) { + if let Some(count) = counts.get(&o.parent) { + let n = next.entry(o.component).or_default(); + *n = (*n + count).min(2); + } + } + counts = next; + } + let ambiguous = counts.get(&component).copied().unwrap_or(0) > 1; + // Slash and reserved-marker names could alias concatenated labels or fallback keys. + let reserved = + self.occurrences.iter().any(|o| o.name.contains('/') || o.name.contains("@occ[")) || st.bodies.iter().any(|b| b.name.contains("@occ[")); + if !ambiguous && !reserved && st.body(&candidate).is_none() { + return candidate; + } + let ids = path.iter().map(u64::to_string).collect::>().join("/"); + let encoded = serde_json::to_string(source).unwrap_or_else(|_| source.to_string()); + let mut key = format!("{encoded}@occ[{ids}]"); + while st.body(&key).is_some() { + key.push('#'); + } + key + } + /// The occurrence path of a component's first placement. pub fn first_path(&self, component: u64) -> Vec { let mut path = Vec::new(); diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs index ffd465e4..97fa631c 100644 --- a/crates/doc/src/config.rs +++ b/crates/doc/src/config.rs @@ -6,6 +6,10 @@ use serde::{Deserialize, Serialize}; use crate::{DocError, Document, Result}; +#[path = "config_rules.rs"] +mod rules; +pub use rules::*; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Column { @@ -32,14 +36,24 @@ pub struct ConfigTable { pub rows: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub active: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, } pub const MAX_COLUMNS: usize = 500; pub const MAX_ROWS: usize = 2000; +pub fn suppression_cell(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Ok(true), + "false" | "0" | "no" => Ok(false), + _ => Err(DocError::Invalid("suppression must be true or false".into())), + } +} + impl ConfigTable { pub fn is_empty(&self) -> bool { - self.columns.is_empty() && self.rows.is_empty() + self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty() } } @@ -75,10 +89,46 @@ pub fn material_cell(value: &str) -> Result> { } impl Document { + /// Freeze design-unit-sensitive configuration literals when changing display units. + /// Explicit units and nonliteral expressions retain their source text. + pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) { + let feature_lengths: Vec = self + .features + .iter() + .flat_map(|feature| { + feature + .param_names + .iter() + .zip(feature.kind.inputs()) + .filter(|(_, (_, _, kind))| *kind == crate::expr::Kind::Length) + .map(|(name, _)| name.clone()) + }) + .collect(); + let lengths: std::collections::BTreeSet = feature_lengths.into_iter().chain(kept_parameters.iter().cloned()).collect(); + for row in &mut self.configs.rows { + for (column, cell) in self.configs.columns.iter().zip(&mut row.cells) { + if matches!(column,Column::Param(name) if lengths.contains(name)) && cell.trim().parse::().is_ok() { + *cell = format!("{} {previous_unit}", cell.trim()); + } + } + } + for rule in &mut self.configs.rules { + rule.preserve_length_units(&lengths, previous_unit); + } + } + /// A column's current value in the design. pub fn config_current(&self, c: &Column) -> Option { match c { - Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e), + Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, kind)| { + // Feature literals are stored in base units, unlike newly typed bare + // row cells. Capture their unit explicitly before editing in an inch design. + if self.param(n).is_none() && kind == crate::expr::Kind::Length && self.units != "mm" && e.trim().parse::().is_ok() { + format!("{} mm", e.trim()) + } else { + e + } + }), Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()), Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())), Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()), @@ -97,27 +147,34 @@ impl Document { /// Set the design to a row's values (all or nothing). pub fn apply_configuration(&mut self, row: &str) -> Result<()> { + self.apply_configuration_with_trace(row).map(|_| ()) + } + + pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result> { let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?; if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() { return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into())); } let mut next = self.clone(); + let changes: Vec<(String, String)> = self + .configs + .columns + .iter() + .zip(&r.cells) + .filter_map(|(c, v)| match c { + Column::Param(name) => Some((name.clone(), v.clone())), + _ => None, + }) + .collect(); + next.change_params_atomic(&changes).map_err(|e| DocError::Invalid(format!("configuration `{row}`: {e}")))?; for (c, v) in self.configs.columns.iter().zip(&r.cells) { if v.len() > 4096 { return Err(DocError::Invalid("configuration value too long".into())); } match c { - Column::Param(n) => { - if next.config_current(c).as_deref() != Some(v.as_str()) { - next.change_param(n, v, None, None).map_err(|e| DocError::Invalid(format!("configuration `{row}`, parameter `{n}`: {e}")))?; - } - } + Column::Param(_) => {} Column::Suppress(id) => { - let on = match v.trim() { - "true" | "1" | "yes" => true, - "false" | "0" | "no" => false, - o => return Err(DocError::Invalid(format!("configuration `{row}`: suppression must be true or false, not `{o}`"))), - }; + let on = suppression_cell(v)?; let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?; f.suppressed = on; } @@ -140,8 +197,9 @@ impl Document { } } next.configs.active = Some(row.to_string()); + let trace = next.evaluate_configuration_rules()?; *self = next; - Ok(()) + Ok(trace) } /// The active row's values all hold in the design. @@ -150,9 +208,24 @@ impl Document { if r.cells.len() != self.configs.columns.len() { return false; } + if !self.configs.rules.is_empty() { + let mut expected = self.clone(); + if expected.apply_configuration(&r.name).is_err() { + return false; + } + return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column)); + } self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c { - Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()), - Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")), + Column::Suppress(id) => suppression_cell(v).is_ok_and(|on| self.feature(*id).is_some_and(|f| f.suppressed == on)), + Column::Param(name) => { + let kind = self + .features + .iter() + .find_map(|f| f.param_names.iter().position(|n| n == name).and_then(|k| f.kind.inputs().get(k).map(|input| input.2))); + let expected = kind.map_or_else(|| v.trim().to_string(), |kind| self.with_design_unit(v, kind)); + self.config_current(c) + .is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace()))) + } Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()), Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()), }) @@ -163,6 +236,53 @@ impl Document { mod tests { use super::*; + #[test] + fn rows_use_final_graph_and_missing_targets_never_reappear() { + let mut d = Document::new("A"); + d.set_param("a", "1 mm", None, None).unwrap(); + d.set_param("b", "a", None, None).unwrap(); + d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())]; + d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] }); + d.apply_configuration("Reordered").unwrap(); + assert!(d.configuration_matches()); + assert_eq!(d.param_values().0["a"].v, 2.0); + d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] }); + let before = d.clone(); + assert!(d.apply_configuration("Cycle").is_err()); + assert_eq!(d, before); + d.params.retain(|p| p.name != "a"); + let before = d.clone(); + assert!(d.apply_configuration("Reordered").is_err()); + assert_eq!(d, before); + } + + #[test] + fn bulk_changes_reject_new_errors_in_unchanged_dependents() { + let mut d = Document::new("A"); + d.set_param("length", "2 mm", None, None).unwrap(); + d.set_param("twice", "length * 2", None, None).unwrap(); + let before = d.clone(); + assert!(d.change_params_atomic(&[("length".into(), "1 / 0".into())]).is_err()); + assert_eq!(d, before); + assert!(d.change_params_atomic(&[("length".into(), "3 mm".into()), ("length".into(), "4 mm".into())]).is_err()); + assert_eq!(d, before); + } + + #[test] + fn unchanged_erroneous_configured_parameter_is_a_noop_but_changed_error_rejects() { + let mut d = Document::new("Existing broken design"); + d.set_param("width", "10 mm", None, None).unwrap(); + d.params[0].expr = "1/".into(); + d.configs.columns = vec![Column::Param("width".into())]; + d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] }); + d.apply_configuration("Default").unwrap(); + assert!(d.configuration_matches()); + d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] }); + let before = d.clone(); + assert!(d.apply_configuration("Different error").is_err()); + assert_eq!(d, before); + } + #[test] fn rows_switch_parameters() { let mut d = Document::new("A"); diff --git a/crates/doc/src/config_rules.rs b/crates/doc/src/config_rules.rs new file mode 100644 index 00000000..f186fbf4 --- /dev/null +++ b/crates/doc/src/config_rules.rs @@ -0,0 +1,646 @@ +//! Bounded typed configuration rules. The saved row remains the editable source; +//! ordered rules derive an effective design without overwriting its cells. +use super::Column; +use crate::{DocError, Document, Result, expr}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +pub const MAX_RULES: usize = 128; +const MAX_NODES: usize = 64; +const MAX_DEPTH: usize = 8; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigRule { + pub id: String, + pub name: String, + #[serde(default = "enabled")] + pub enabled: bool, + /// Unit context for feature-length expressions, fixed when authoring a rule. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub length_unit: Option, + pub condition: RuleCondition, + pub effect: RuleEffect, +} +fn enabled() -> bool { + true +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum RuleCondition { + Always, + All { conditions: Vec }, + Any { conditions: Vec }, + Not { condition: Box }, + Parameter { parameter: String, operator: RuleOperator, expression: String }, + Suppression { feature: u64, value: bool }, +} +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuleOperator { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum RuleEffect { + DriveParameter { parameter: String, expression: String }, + DriveSuppression { feature: u64, value: bool }, + ParameterRange { parameter: String, minimum: String, maximum: String }, + AllowParameterValues { parameter: String, expressions: Vec }, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RuleTrace { + pub id: String, + pub name: String, + pub matched: bool, + pub effect: RuleEffect, +} + +/// Check saved rule structure before recursive serde decoding. Absence is backward +/// compatible; malformed present data is refused. Runtime evaluation checks it again. +pub fn validate_rules_json(document: &Value) -> Result<()> { + let Some(rules) = document.get("configs").and_then(|configs| configs.get("rules")) else { return Ok(()) }; + let rules = rules.as_array().ok_or_else(|| invalid("rules must be an array"))?; + if rules.len() > MAX_RULES { + return Err(invalid("too many rules")); + } + for rule in rules { + let rule = rule.as_object().ok_or_else(|| invalid("rule must be an object"))?; + for (key, limit) in [("id", 64), ("name", 128)] { + let value = rule.get(key).and_then(Value::as_str).ok_or_else(|| invalid("rule id and name are required"))?; + if value.trim().is_empty() || value.chars().count() > limit { + return Err(invalid("rule id or name is empty or too long")); + } + } + if let Some(unit) = rule.get("length_unit") { + let unit = + unit.as_str().filter(|unit| matches!(*unit, "mm" | "cm" | "m" | "in" | "ft")).ok_or_else(|| invalid("invalid rule length unit"))?; + let _ = unit; + } + let mut pending = vec![(rule.get("condition").ok_or_else(|| invalid("condition required"))?, 1usize)]; + let mut count = 0; + while let Some((condition, depth)) = pending.pop() { + count += 1; + if depth > MAX_DEPTH || count > MAX_NODES { + return Err(invalid("condition nesting or node limit exceeded")); + } + let kind = condition.get("type").and_then(Value::as_str).ok_or_else(|| invalid("condition type required"))?; + match kind { + "all" | "any" => { + let children = condition.get("conditions").and_then(Value::as_array).ok_or_else(|| invalid("conditions must be an array"))?; + if children.is_empty() || children.len() > MAX_NODES { + return Err(invalid("condition group is empty or too large")); + } + pending.extend(children.iter().map(|child| (child, depth + 1))); + } + "not" => pending.push((condition.get("condition").ok_or_else(|| invalid("nested condition required"))?, depth + 1)), + "parameter" => { + check_parameter(condition.get("parameter"))?; + check_expression(condition.get("expression"))?; + } + "always" | "suppression" => {} + _ => return Err(invalid("unknown condition type")), + } + } + let effect = rule.get("effect").ok_or_else(|| invalid("effect required"))?; + if effect.get("type").and_then(Value::as_str) != Some("drive_suppression") { + check_parameter(effect.get("parameter"))?; + } + match effect.get("type").and_then(Value::as_str) { + Some("drive_parameter") => check_expression(effect.get("expression"))?, + Some("parameter_range") => { + check_expression(effect.get("minimum"))?; + check_expression(effect.get("maximum"))?; + } + Some("allow_parameter_values") => { + let expressions = effect.get("expressions").and_then(Value::as_array).ok_or_else(|| invalid("allowed values must be an array"))?; + if expressions.is_empty() || expressions.len() > 128 { + return Err(invalid("allowed values are empty or too many")); + } + for expression in expressions { + check_expression(Some(expression))?; + } + } + Some("drive_suppression") => {} + _ => return Err(invalid("unknown effect type")), + } + } + Ok(()) +} +fn check_expression(value: Option<&Value>) -> Result<()> { + let text = value.and_then(Value::as_str).ok_or_else(|| invalid("expression must be text"))?; + if text.trim().is_empty() || text.len() > 4096 { + return Err(invalid("expression is empty or too long")); + } + Ok(()) +} +fn check_parameter(value: Option<&Value>) -> Result<()> { + let name = value.and_then(Value::as_str).ok_or_else(|| invalid("parameter name required"))?; + if name.is_empty() || name.len() > 64 || Document::param_name_problem(name).is_some() { + return Err(invalid("invalid parameter name")); + } + Ok(()) +} +fn invalid(message: &str) -> DocError { + DocError::Invalid(format!("configuration rules: {message}")) +} + +fn operands( + doc: &Document, + values: &BTreeMap, + parameter: &str, + expression: &str, + length_unit: Option<&str>, +) -> Result<(f64, f64)> { + let lhs = values.get(parameter).copied().ok_or_else(|| invalid(&format!("parameter `{parameter}` is missing or invalid")))?; + let unit = doc + .param(parameter) + .map(|p| p.unit.as_str()) + .or_else(|| lhs.kind().map(|kind| if kind == expr::Kind::Length { length_unit.unwrap_or(doc.units.as_str()) } else { kind.default_unit() })) + .unwrap_or(""); + let defaults = expr::Defaults::for_unit(unit, expr::unit_info(&doc.units).map(|(_, scale)| scale).unwrap_or(1.0)); + let rhs = expr::eval_with_defaults( + expression, + &|name| values.get(name).copied().ok_or_else(|| invalid(&format!("unknown parameter `{name}`"))), + defaults, + )?; + let kind = lhs.kind().ok_or_else(|| invalid("comparison requires length, angle or unitless values"))?; + let right = rhs.to_kind_in(kind, defaults)?; + if !lhs.v.is_finite() || !right.is_finite() { + return Err(invalid("comparison requires finite values")); + } + Ok((lhs.v, right)) +} +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0) +} +fn validate_drive_graph(graph: &BTreeMap>, parameter: &str, expression: &str) -> Result<()> { + let mut pending = expr::references(expression); + let mut visited = BTreeSet::new(); + while let Some(name) = pending.pop() { + if name == parameter { + return Err(invalid(&format!("drive for `{parameter}` creates a parameter cycle"))); + } + if visited.insert(name.clone()) + && let Some(references) = graph.get(&name) + { + pending.extend(references.iter().cloned()); + } + } + Ok(()) +} +fn condition(doc: &Document, values: &BTreeMap, value: &RuleCondition, length_unit: Option<&str>) -> Result { + match value { + RuleCondition::Always => Ok(true), + RuleCondition::All { conditions } => { + for c in conditions { + if !condition(doc, values, c, length_unit)? { + return Ok(false); + } + } + Ok(true) + } + RuleCondition::Any { conditions } => { + for c in conditions { + if condition(doc, values, c, length_unit)? { + return Ok(true); + } + } + Ok(false) + } + RuleCondition::Not { condition: c } => Ok(!condition(doc, values, c, length_unit)?), + RuleCondition::Suppression { feature, value } => { + Ok(doc.feature(*feature).ok_or_else(|| invalid("condition feature is gone"))?.suppressed == *value) + } + RuleCondition::Parameter { parameter, operator, expression } => { + let (a, b) = operands(doc, values, parameter, expression, length_unit)?; + Ok(match operator { + RuleOperator::Eq => close(a, b), + RuleOperator::Ne => !close(a, b), + RuleOperator::Lt => a < b, + RuleOperator::Le => a <= b || close(a, b), + RuleOperator::Gt => a > b, + RuleOperator::Ge => a >= b || close(a, b), + }) + } + } +} + +impl Document { + /// Apply ordered drives, then enforce all matched restrictions on the final state. + /// Running on a scratch document makes a failing rule fully atomic for callers. + pub(super) fn evaluate_configuration_rules(&mut self) -> Result> { + validate_rules_json(&serde_json::json!({"configs":{"rules":self.configs.rules}}))?; + let mut ids = BTreeSet::new(); + let mut trace = Vec::new(); + let mut restrictions = Vec::new(); + let (mut values, _) = self.param_values(); + let parameter_names: BTreeSet = self.all_param_exprs().into_iter().map(|(name, _, _)| name).collect(); + let mut dependency_graph: BTreeMap> = + self.all_param_exprs().into_iter().map(|(name, expression, _)| (name, expr::references(&expression))).collect(); + for rule in self.configs.rules.clone() { + if !ids.insert(rule.id.clone()) { + return Err(invalid("duplicate rule id")); + } + if let Some(name) = rule.parameter_references().iter().find(|name| !parameter_names.contains(*name)) { + return Err(invalid(&format!("rule `{}` references missing parameter `{name}`", rule.name))); + } + // Targets must remain configured, including disabled rules, so dormant rules + // cannot silently become invalid and surprise a subsequent activation. + let target = match &rule.effect { + RuleEffect::DriveParameter { parameter, .. } + | RuleEffect::ParameterRange { parameter, .. } + | RuleEffect::AllowParameterValues { parameter, .. } => Column::Param(parameter.clone()), + RuleEffect::DriveSuppression { feature, .. } => Column::Suppress(*feature), + }; + if !self.configs.columns.contains(&target) { + return Err(invalid(&format!("rule `{}` target is not configured", rule.name))); + } + let matched = rule.enabled + && condition(self, &values, &rule.condition, rule.length_unit.as_deref()) + .map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?; + trace.push(RuleTrace { id: rule.id, name: rule.name.clone(), matched, effect: rule.effect.clone() }); + if !matched { + continue; + } + match rule.effect { + RuleEffect::DriveParameter { parameter, mut expression } => { + validate_drive_graph(&dependency_graph, ¶meter, &expression)?; + dependency_graph.insert(parameter.clone(), expr::references(&expression)); + if self.param(¶meter).is_none() + && rule.length_unit.as_deref().is_some_and(|unit| unit != self.units) + && values.get(¶meter).is_some_and(|value| value.kind() == Some(expr::Kind::Length)) + { + let (_, value) = operands(self, &values, ¶meter, &expression, rule.length_unit.as_deref())?; + expression = format!("{value} mm"); + } + self.change_params_atomic(&[(parameter, expression)]).map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?; + values = self.param_values().0; + } + RuleEffect::DriveSuppression { feature, value } => { + self.feature_mut(feature).ok_or_else(|| invalid("rule feature is gone"))?.suppressed = value + } + effect => restrictions.push((rule.name, effect, rule.length_unit)), + } + } + for (name, effect, length_unit) in restrictions { + let valid = match effect { + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + let (value, min) = operands(self, &values, ¶meter, &minimum, length_unit.as_deref())?; + let (_, max) = operands(self, &values, ¶meter, &maximum, length_unit.as_deref())?; + min <= max && (value >= min || close(value, min)) && (value <= max || close(value, max)) + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + let mut valid = false; + for expression in expressions { + let (a, b) = operands(self, &values, ¶meter, &expression, length_unit.as_deref())?; + valid |= close(a, b); + } + valid + } + _ => true, + }; + if !valid { + return Err(invalid(&format!("rule `{name}` restricts the final value"))); + } + } + Ok(trace) + } +} + +impl ConfigRule { + pub(super) fn preserve_length_units(&mut self, parameters: &BTreeSet, unit: &str) { + if self.length_unit.is_none() { + self.length_unit = Some(unit.to_string()); + } + fn annotate(parameter: &str, expression: &mut String, parameters: &BTreeSet, unit: &str) { + if parameters.contains(parameter) && expression.trim().parse::().is_ok() { + *expression = format!("{} {unit}", expression.trim()); + } + } + fn walk(condition: &mut RuleCondition, parameters: &BTreeSet, unit: &str) { + match condition { + RuleCondition::Parameter { parameter, expression, .. } => annotate(parameter, expression, parameters, unit), + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => { + for condition in conditions { + walk(condition, parameters, unit); + } + } + RuleCondition::Not { condition } => walk(condition, parameters, unit), + _ => {} + } + } + walk(&mut self.condition, parameters, unit); + match &mut self.effect { + RuleEffect::DriveParameter { parameter, expression } => annotate(parameter, expression, parameters, unit), + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + annotate(parameter, minimum, parameters, unit); + annotate(parameter, maximum, parameters, unit); + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + for expression in expressions { + annotate(parameter, expression, parameters, unit); + } + } + _ => {} + } + } + + /// Parameter references include targets and condition/effect expressions. + pub fn parameter_references(&self) -> Vec { + let mut copy = self.clone(); + let mut references = Vec::new(); + copy.visit_parameters(&mut |text, expression| { + if expression { + references.extend(expr::references(text)); + } else { + references.push(text.clone()); + } + }); + references + } + pub fn rename_parameter(&mut self, old: &str, new: &str) { + self.visit_parameters(&mut |text, expression| { + if expression { + *text = expr::rename_reference(text, old, new); + } else if text == old { + *text = new.to_string(); + } + }); + } + fn visit_parameters(&mut self, visit: &mut impl FnMut(&mut String, bool)) { + fn walk(c: &mut RuleCondition, visit: &mut impl FnMut(&mut String, bool)) { + match c { + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => { + for c in conditions { + walk(c, visit); + } + } + RuleCondition::Not { condition } => walk(condition, visit), + RuleCondition::Parameter { parameter, expression, .. } => { + visit(parameter, false); + visit(expression, true); + } + _ => {} + } + } + walk(&mut self.condition, visit); + match &mut self.effect { + RuleEffect::DriveParameter { parameter, expression } => { + visit(parameter, false); + visit(expression, true); + } + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + visit(parameter, false); + visit(minimum, true); + visit(maximum, true); + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + visit(parameter, false); + for e in expressions { + visit(e, true); + } + } + _ => {} + } + } + pub fn references_features(&self, ids: &[u64]) -> bool { + fn condition(c: &RuleCondition, ids: &[u64]) -> bool { + match c { + RuleCondition::Suppression { feature, .. } => ids.contains(feature), + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => conditions.iter().any(|c| condition(c, ids)), + RuleCondition::Not { condition: c } => condition(c, ids), + _ => false, + } + } + condition(&self.condition, ids) || matches!(&self.effect,RuleEffect::DriveSuppression {feature,..} if ids.contains(feature)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigRow; + fn design() -> Document { + let mut doc = Document::new("Rules"); + doc.set_param("width", "10 mm", None, None).unwrap(); + doc.set_param("depth", "5 mm", None, None).unwrap(); + doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())]; + doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] }); + doc + } + fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule { + ConfigRule { id: id.into(), name: id.into(), enabled: true, length_unit: None, condition, effect } + } + #[test] + fn ordered_rules_derive_values_without_overwriting_rows() { + let mut doc = design(); + doc.configs.rules = vec![ + rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "1 in".into() }), + rule( + "second", + RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Gt, expression: "20 mm".into() }, + RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() }, + ), + ]; + let source = doc.configs.rows.clone(); + let trace = doc.apply_configuration_with_trace("Default").unwrap(); + assert!(trace.iter().all(|entry| entry.matched)); + assert!((doc.param_values().0["depth"].v - 12.7).abs() < 1e-9); + assert_eq!(doc.configs.rows, source); + assert!(doc.configuration_matches()); + doc.configs.rules.swap(0, 1); + let trace = doc.apply_configuration_with_trace("Default").unwrap(); + assert!(!trace[0].matched); + assert_eq!(doc.param_values().0["depth"].v, 5.0); + } + #[test] + fn restrictions_intersect_and_fail_atomically_with_named_rule() { + let mut doc = design(); + doc.configs.rules = vec![ + rule( + "range", + RuleCondition::Always, + RuleEffect::ParameterRange { parameter: "width".into(), minimum: "5 mm".into(), maximum: "1 in".into() }, + ), + rule( + "choices", + RuleCondition::Always, + RuleEffect::AllowParameterValues { parameter: "width".into(), expressions: vec!["1 cm".into(), "20 mm".into()] }, + ), + ]; + doc.apply_configuration("Default").unwrap(); + doc.configs.rows[0].cells[0] = "15 mm".into(); + let before = doc.clone(); + assert!(doc.apply_configuration("Default").unwrap_err().to_string().contains("choices")); + assert_eq!(doc, before); + doc.configs.rules[0].effect = RuleEffect::ParameterRange { parameter: "width".into(), minimum: "1 deg".into(), maximum: "90 deg".into() }; + assert!(doc.apply_configuration("Default").is_err()); + } + #[test] + fn rule_references_follow_rename_and_prevent_delete() { + let mut doc = design(); + doc.configs.rules.push(rule( + "derive", + RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Ge, expression: "depth".into() }, + RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() }, + )); + assert!(doc.remove_param("width").is_err()); + doc.rename_param("width", "span").unwrap(); + let refs = doc.configs.rules[0].parameter_references(); + assert!(refs.contains(&"span".into())); + assert!(!refs.contains(&"width".into())); + doc.apply_configuration("Default").unwrap(); + assert_eq!(doc.param_values().0["depth"].v, 5.0); + } + #[test] + fn angle_and_unitless_comparisons_keep_their_dimensions() { + let mut doc = design(); + doc.set_param("angle", "45 deg", Some("deg"), None).unwrap(); + doc.set_param("count", "1", Some(""), None).unwrap(); + doc.configs.columns.push(Column::Param("count".into())); + doc.configs.rows[0].cells.push("1".into()); + doc.configs.rules.push(rule( + "angle-drive", + RuleCondition::Parameter { parameter: "angle".into(), operator: RuleOperator::Gt, expression: "1".into() }, + RuleEffect::DriveParameter { parameter: "count".into(), expression: "2".into() }, + )); + let trace = doc.apply_configuration_with_trace("Default").unwrap(); + assert!(trace[0].matched); + assert_eq!(doc.param_values().0["count"].v, 2.0); + doc.configs.rules[0].condition = + RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "1 deg".into() }; + let before = doc.clone(); + assert!(doc.apply_configuration("Default").is_err()); + assert_eq!(doc, before); + } + + #[test] + fn saved_rule_budgets_are_checked_before_deserialization() { + let valid = serde_json::to_value(rule( + "r", + RuleCondition::Always, + RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() }, + )) + .unwrap(); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[valid.clone()]}})).is_ok()); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":null}})).is_err()); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();129]}})).is_err()); + let mut condition = serde_json::json!({"type":"always"}); + for _ in 0..8 { + condition = serde_json::json!({"type":"not","condition":condition}); + } + let mut deep = valid.clone(); + deep["condition"] = condition; + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[deep]}})).is_err()); + let mut wide = valid.clone(); + wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]}); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[wide]}})).is_err()); + let mut long = valid.clone(); + long["effect"]["expression"] = serde_json::json!("a".repeat(4097)); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[long]}})).is_err()); + let mut malformed = valid; + malformed["condition"] = serde_json::json!({"type":"any","conditions":{}}); + assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[malformed]}})).is_err()); + } + + #[test] + fn compound_conditions_short_circuit_guarded_numeric_expressions() { + let mut doc = design(); + doc.set_param("count", "0", Some(""), None).unwrap(); + let guard = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "0".into() }; + let invalid_when_zero = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "1 / count".into() }; + doc.configs.rules = vec![rule( + "guarded", + RuleCondition::All { conditions: vec![guard, invalid_when_zero.clone()] }, + RuleEffect::DriveParameter { parameter: "width".into(), expression: "20 mm".into() }, + )]; + assert!(!doc.apply_configuration_with_trace("Default").unwrap()[0].matched); + assert_eq!(doc.param_values().0["width"].v, 10.0); + doc.configs.rules[0].condition = RuleCondition::Any { + conditions: vec![ + RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "0".into() }, + invalid_when_zero, + ], + }; + assert!(doc.apply_configuration_with_trace("Default").unwrap()[0].matched); + assert_eq!(doc.param_values().0["width"].v, 20.0); + } + + #[test] + fn saved_documents_reject_malformed_deep_and_oversized_rules_before_typed_decode() { + let valid = serde_json::to_value(rule( + "r", + RuleCondition::Always, + RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() }, + )) + .unwrap(); + let mut deep = valid.clone(); + let mut condition = serde_json::json!({"type":"always"}); + for _ in 0..8 { + condition = serde_json::json!({"type":"not","condition":condition}); + } + deep["condition"] = condition; + let mut wide = valid.clone(); + wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]}); + let mut invalid_context = valid.clone(); + invalid_context["length_unit"] = serde_json::json!("mile".repeat(1000)); + for invalid in [ + Value::Null, + serde_json::json!([deep]), + serde_json::json!([wide]), + serde_json::json!(vec![valid; 129]), + serde_json::json!([invalid_context]), + ] { + let mut saved = serde_json::to_value(design()).unwrap(); + saved["configs"]["rules"] = invalid; + let error = crate::format::read(&saved.to_string()).unwrap_err().to_string(); + assert!(error.contains("configuration rules"), "saved boundary must report bounded rule validation: {error}"); + } + } + + #[test] + fn cross_unit_effective_literals_keep_declared_drive_cycle_checks() { + let mut doc = Document::new("Cross-unit drive graph"); + doc.add_feature( + crate::FeatureKind::Box { + corner: ["0".into(), "0".into(), "0".into()], + length: "10 mm".into(), + width: "5 mm".into(), + height: "10 mm".into(), + operation: crate::Operation::NewBody, + }, + None, + ) + .unwrap(); + let width = doc.features[0].param_names[0].clone(); + let depth = doc.features[0].param_names[1].clone(); + doc.rename_param(&width, "width").unwrap(); + doc.rename_param(&depth, "depth").unwrap(); + doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())]; + doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] }); + doc.units = "in".into(); + let mut first = + rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() }); + first.length_unit = Some("mm".into()); + let mut second = + rule("second", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width + 1".into() }); + second.length_unit = Some("mm".into()); + doc.configs.rules = vec![first]; + doc.apply_configuration("Default").unwrap(); + assert_eq!(doc.config_current(&Column::Param("width".into())).as_deref(), Some("6 mm")); + doc.configs.rules.push(second); + let before = doc.clone(); + assert!(doc.apply_configuration("Default").is_err()); + assert_eq!(doc, before); + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 0fce5008..f9085052 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1586,7 +1586,7 @@ impl Document { /// else its component's, else its material's look, else the colour it was imported with. /// (Faces may have their own: `face_looks`.) pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> { - self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color)) + self.model_body_look(b).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color)) } /// The model with each body carrying the colour, opacity and face colours it is shown with @@ -1595,7 +1595,7 @@ impl Document { let mut st = state.clone(); for b in &mut st.bodies { let c = self.body_color(b); - let look = self.body_look(&b.name, b.feature); + let look = self.model_body_look(b); let faces: Vec = self .face_colors(b) .into_iter() @@ -1739,9 +1739,22 @@ impl Document { let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?; let mut gone = vec![id]; gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id)); + let gone_params: Vec = self.features.iter().filter(|f| gone.contains(&f.id)).flat_map(|f| f.param_names.clone()).collect(); + self.configs + .rules + .retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name))); self.features.retain(|f| !gone.contains(&f.id)); // Configuration columns of deleted features go with them. - let keep: Vec = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect(); + let keep: Vec = self + .configs + .columns + .iter() + .map(|c| match c { + crate::config::Column::Suppress(id) => !gone.contains(id), + crate::config::Column::Param(name) => !gone_params.contains(name), + _ => true, + }) + .collect(); if keep.contains(&false) { let mut k = keep.iter(); self.configs.columns.retain(|_| k.next().copied().unwrap_or(true)); @@ -1768,6 +1781,19 @@ impl Document { fn referenced_names(&self) -> Vec { let mut used: Vec = Vec::new(); + for rule in &self.configs.rules { + used.extend(rule.parameter_references()); + } + for (index, column) in self.configs.columns.iter().enumerate() { + if let crate::config::Column::Param(name) = column { + used.push(name.clone()); + for row in &self.configs.rows { + if let Some(value) = row.cells.get(index) { + used.extend(expr::references(value)); + } + } + } + } for f in &self.features { for e in f.kind.expressions() { used.extend(expr::references(e)); @@ -1960,6 +1986,9 @@ impl Document { c.kind.set_value(v); } } + for c in &sk.constraints { + sk.validate(&c.kind)?; + } Ok(()) } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 5dba61bb..bfd25e24 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -16,6 +16,10 @@ use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operati #[derive(Clone, Debug)] pub struct ModelBody { pub name: String, + /// Definition identity for a computed placement; never parsed from its display name. + pub source_name: Option, + /// Definition geometry coordinates to this computed body's frame. + pub source_transform: crate::Mat, pub body: Body, /// Feature that created it. pub feature: u64, @@ -35,7 +39,21 @@ pub struct ModelBody { impl ModelBody { pub fn new(name: String, body: Body, feature: u64) -> Self { - ModelBody { name, body, feature, mesh: Arc::new(OnceLock::new()), key: 0, names: Default::default(), component: 0, imported: None } + ModelBody { + name, + source_name: None, + source_transform: crate::IDENTITY, + body, + feature, + mesh: Arc::new(OnceLock::new()), + key: 0, + names: Default::default(), + component: 0, + imported: None, + } + } + pub fn definition_name(&self) -> &str { + self.source_name.as_deref().unwrap_or(&self.name) } /// Display mesh (chord tolerance 1/1000 of the body size), computed once. pub fn mesh(&self) -> Arc { @@ -807,6 +825,8 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation } Operation::Cut | Operation::Intersect => { let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect }; + let affected: std::collections::BTreeSet = + target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect(); let mut remove = Vec::new(); for &ti in &target_idx { let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.body.clone())) else { continue }; @@ -834,6 +854,10 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation // A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"). let mut split: Vec = Vec::new(); for mb in std::mem::take(&mut state.bodies) { + if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) { + split.push(mb); + continue; + } let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]); if pieces.len() < 2 { split.push(mb); @@ -2370,11 +2394,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { let handled = more::sheet_cut(f, st, &tools, targets)?; if !handled.is_empty() { + let effective = if targets.is_empty() { &f.participants } else { targets }; + let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*"); let rest: Vec = st .bodies .iter() + .filter(|b| { + !handled.contains(&b.name) + && (all + || if effective.is_empty() { + b.component == f.component || b.feature == f.id + } else { + effective.contains(&b.name) + }) + }) .map(|b| b.name.clone()) - .filter(|n| !handled.contains(n) && (targets.is_empty() || targets.contains(n))) .collect(); if rest.is_empty() { return Ok(()); @@ -2873,14 +2907,19 @@ fn unique_body_name(st: &ModelState, base: &str) -> String { /// face finds the face where it is shown and projects its edges there. The model itself when /// every placement agrees (the usual case). pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> { + state_in_frame_at(doc, st, comp, doc.component_transform(comp)) +} + +/// As `state_in_frame`, using the exact active placement for its shared source geometry. +pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, frame: Mat) -> std::borrow::Cow<'a, ModelState> { use std::borrow::Cow; if doc.occurrences.is_empty() && doc.body_offsets.is_empty() { return Cow::Borrowed(st); } - let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) }; + let Some(inv) = crate::mat_inverse(&frame) else { return Cow::Borrowed(st) }; let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0); let rel = |c: u64, body: Option<&str>| { - let m = crate::mat_mul(&inv, &doc.component_transform(c)); + let m = crate::mat_mul(&inv, &if c == comp { frame } else { doc.component_transform(c) }); match body.and_then(|b| doc.body_offsets.get(b)) { Some(o) => crate::mat_mul(&m, o), None => m, @@ -2900,6 +2939,9 @@ pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std: let names = crate::naming::face_names(b); let mut nb = ModelBody::new(b.name.clone(), t, b.feature); nb.component = b.component; + nb.source_name = b.source_name.clone(); + nb.source_transform = crate::mat_mul(m, &b.source_transform); + nb.imported = b.imported.clone(); nb.names = crate::naming::NameCell::known(names); *b = nb; } @@ -2938,17 +2980,18 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { let mut out = ModelState { body_counter: st.body_counter, ..Default::default() }; for (comp, path, m) in doc.placements() { let primary = path == first_path(comp); - let label = || -> String { - path.iter().filter_map(|id| doc.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::>().join("/") - }; let ident = crate::is_identity(&m); for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) { - let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) }; + let name = doc.world_body_name(st, &b.name, comp, &path); // A body moved in from another component keeps its place (its offset). let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m); let body = if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) }; let mut placed = ModelBody::new(name, body, b.feature); + placed.source_name = Some(b.definition_name().to_string()); + placed.source_transform = crate::mat_mul(&bm, &b.source_transform); + placed.component = comp; + placed.imported = b.imported.clone(); // Placement preserves topology: face ownership remains that of the // source model, including modifiers on component instances. placed.names = crate::naming::NameCell::known(crate::naming::face_names(b)); diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 84b30e53..4c7a0ec8 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -497,17 +497,22 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target } } let mut handled = Vec::new(); + let mut overlap_work = crate::sheet::FlatWork::new(); + let targets = if targets.is_empty() { &f.participants } else { targets }; + let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); for i in 0..st.sheets.len() { let Some(sh) = st.sheets.get(i).cloned() else { continue }; - if !targets.is_empty() && !targets.contains(&sh.body) { + let eligible = all + || if targets.is_empty() { + st.bodies.iter().any(|b| b.name == sh.body && (b.component == f.component || b.feature == f.id)) + } else { + targets.contains(&sh.body) + }; + if !eligible { continue; } let candidates: Vec<(Option, Region2)> = if sh.flat { - if sh.frame.normal().cross(n).len() > 1e-6 { - Vec::new() - } else { - vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })] - } + if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|region| (None, region)).collect() } } else { sh.parallel_panels(&ss.plane) }; @@ -520,11 +525,9 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 { continue; } - let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::>() } else { panel.outer.polyline(1e-3) }; for r in ®ions { - let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue }; - let fp = flat.outer.polyline(1e-3); - let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p)); + let flat = sh.region_to_flat(pi, &ss.plane, r)?; + let overlaps = crate::sheet::flat_regions_overlap(&panel, &flat, &mut overlap_work)?; if overlaps && !added.iter().any(|x: &Region2| x == &flat) { added.push(flat); } @@ -542,18 +545,6 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target Ok(handled) } -fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool { - let mut inside = false; - let m = poly.len(); - for i in 0..m { - let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue }; - if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { - inside = !inside; - } - } - inside -} - /// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance /// `level` from `at` along `n`: the edges used by one of them only, chained. fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec> { diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index cf3cea21..2ee77cfe 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -23,13 +23,15 @@ //! - 7: configuration tables can include body material and appearance columns. Version 6 //! rows need no transformation; the version stamp lets older readers request an update //! instead of encountering an unknown column variant. +//! - 8: ordered configuration rules and persistent signed line-angle, arc-sweep and slot +//! dimensions. Version 7 geometry and table values retain their meaning without changes. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 7; +pub const FORMAT: u32 = 8; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -42,7 +44,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 6] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(())]; +const JSON_STEPS: [JsonStep; 7] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(())]; /// Old XZ references retain their exact frame, including nested offset and angled planes. /// New origin references use +Y; freezing the old frame avoids changing sketch coordinates, @@ -133,6 +135,7 @@ pub fn read(s: &str) -> Result { ))); } check_numbers(&v, 0)?; + crate::config::validate_rules_json(&v)?; for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) { step(&mut v)?; } @@ -242,7 +245,7 @@ mod tests { let upgraded = read(&legacy.to_string()).unwrap(); assert_eq!(upgraded, d, "the identity migration preserves the complete design"); assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table); - assert_eq!(upgraded.format, "solvecraft/7"); + assert_eq!(upgraded.format, format_string(FORMAT)); assert!(upgraded.configuration_matches()); legacy.as_object_mut().unwrap().remove("configs"); assert!(read(&legacy.to_string()).unwrap().configs.is_empty()); @@ -251,7 +254,7 @@ mod tests { } #[test] - fn version_seven_styles_roundtrip_and_newer_versions_are_refused_before_columns() { + fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() { use crate::config::{Column, ConfigRow}; let mut d = Document::new("configured styles"); d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())]; @@ -261,17 +264,72 @@ mod tests { }]; d.apply_configuration("Blue steel").unwrap(); let mut value: Value = serde_json::from_str(&d.to_json()).unwrap(); - assert_eq!(value["format"], "solvecraft/7"); + value["format"] = Value::from("solvecraft/7"); + let original_table = value["configs"].clone(); let roundtrip = read(&value.to_string()).unwrap(); assert_eq!(roundtrip, d); + assert_eq!(serde_json::to_value(&roundtrip.configs).unwrap(), original_table); + assert_eq!(roundtrip.format, "solvecraft/8"); assert!(roundtrip.configuration_matches()); value["format"] = Value::from(format_string(FORMAT + 1)); value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]); + value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true}); let error = read(&value.to_string()).unwrap_err().to_string(); assert!(error.contains("newer SolveCraft") && error.contains("update SolveCraft"), "{error}"); assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants"); } + #[test] + fn version_seven_origin_sketch_keeps_geometry_dimension_ids_and_expressions() { + use solvecraft_geom::Vec2; + use solvecraft_sketch::{ConstraintKind, Sketch}; + let mut d = Document::new("legacy origin dimensions"); + d.set_param("reach", "25 mm", Some("mm"), None).unwrap(); + let dim = d.new_model_param("reach", "mm"); + let mut sketch = Sketch::new(); + let end = sketch.add_point(Vec2::new(25.0, 0.0), Some("tip")).unwrap(); + let line = sketch.add_line_pts(0, end, Some("edge")).unwrap(); + sketch.add_constraint(ConstraintKind::Horizontal { l: line }, None).unwrap(); + sketch.add_constraint(ConstraintKind::Length { l: line, value: 25.0 }, Some(dim)).unwrap(); + d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Dimensioned")).unwrap(); + let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap(); + legacy["format"] = Value::from("solvecraft/7"); + let features = legacy["features"].clone(); + let params = legacy["params"].clone(); + let upgraded = read(&legacy.to_string()).unwrap(); + assert_eq!(upgraded, d); + let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap(); + assert_eq!(upgraded_json["features"], features); + assert_eq!(upgraded_json["params"], params); + assert_eq!(read(&upgraded.to_json()).unwrap(), upgraded); + } + + #[test] + fn version_eight_persistent_angle_sweep_and_slot_dimensions_roundtrip() { + use solvecraft_geom::Vec2; + use solvecraft_sketch::{ConstraintKind, Sketch}; + let mut d = Document::new("persistent typed dimensions"); + let heading = d.new_model_param("45 deg", "deg"); + let sweep = d.new_model_param("180 deg", "deg"); + let overall = d.new_model_param("10 mm", "mm"); + let mut sketch = Sketch::new(); + let end = sketch.add_point(Vec2::new(2.0, 2.0), None).unwrap(); + let line = sketch.add_line_pts(0, end, None).unwrap(); + let a = sketch.add_arc(Vec2::ZERO, Vec2::new(0.0, 2.0), Vec2::new(0.0, -2.0), [None; 3], None).unwrap(); + let b = sketch.add_arc(Vec2::new(6.0, 0.0), Vec2::new(6.0, -2.0), Vec2::new(6.0, 2.0), [None; 3], None).unwrap(); + sketch.add_constraint(ConstraintKind::LineAngle { l: line, value: std::f64::consts::FRAC_PI_4 }, Some(heading)).unwrap(); + sketch.add_constraint(ConstraintKind::ArcSweep { c: a, value: std::f64::consts::PI }, Some(sweep)).unwrap(); + sketch.add_constraint(ConstraintKind::SlotOverallLength { a, b, value: 10.0 }, Some(overall)).unwrap(); + d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let mut saved: Value = serde_json::from_str(&d.to_json()).unwrap(); + assert_eq!(saved["format"], "solvecraft/8"); + assert_eq!(read(&saved.to_string()).unwrap(), d); + saved["format"] = Value::from(format_string(FORMAT + 1)); + saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension"); + let error = read(&saved.to_string()).unwrap_err().to_string(); + assert!(error.contains("newer SolveCraft") && !error.contains("unknown variant"), "{error}"); + } + #[test] fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() { let mut d = Document::new("input repair"); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f4b6cfc9..be6802b2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden pub use document::*; pub use eval::{ EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices, - state_in_frame, world_state, + state_in_frame, state_in_frame_at, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index e2b3d795..e7ce630d 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -502,6 +502,53 @@ impl Document { Ok(()) } + /// Change existing expressions against their final dependency graph, independent of + /// column order. Unlike `change_param`, this never creates a missing parameter. + pub fn change_params_atomic(&mut self, changes: &[(String, String)]) -> Result<()> { + if changes.len() > crate::config::MAX_COLUMNS { + return Err(DocError::Invalid("too many parameter changes".into())); + } + let mut names = BTreeSet::new(); + let mut changed = BTreeSet::new(); + let (_, before_errors) = self.param_values(); + let mut next = self.clone(); + for (name, expression) in changes { + if !names.insert(name.clone()) { + return Err(DocError::Invalid(format!("duplicate parameter `{name}`"))); + } + if expression.len() > 4096 || expression.trim().is_empty() { + return Err(DocError::Expr("expression is empty or too long".into())); + } + if let Some(p) = next.params.iter_mut().find(|p| p.name == *name) { + if p.expr != expression.trim() { + changed.insert(name.clone()); + } + p.expr = expression.trim().to_string(); + continue; + } + let hit = next.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k))); + let Some((i, k)) = hit else { return Err(DocError::Unknown(format!("parameter `{name}`"))) }; + let kind = next.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless); + let normalized = next.with_design_unit(expression, kind); + let Some(feature) = next.features.get_mut(i) else { return Err(DocError::Unknown(format!("parameter `{name}`"))) }; + let Some((_, target, _)) = feature.kind.inputs_mut().into_iter().nth(k) else { + return Err(DocError::Unknown(format!("parameter `{name}`"))); + }; + if *target != normalized { + changed.insert(name.clone()); + } + *target = normalized; + } + let (_, errors) = next.param_values(); + for (name, error) in errors { + if changed.contains(&name) || !before_errors.contains_key(&name) { + return Err(DocError::Expr(format!("parameter `{name}`: {error}"))); + } + } + *self = next; + Ok(()) + } + /// The parameters table (user, sketch dimensions, feature inputs). pub fn param_rows(&self) -> Vec { let (vals, errs) = self.param_values(); @@ -639,6 +686,8 @@ pub enum ParamUser { Feature { feature: u64, name: String, input: String }, /// A sketch dimension (`constraint` is its id). Dimension { feature: u64, name: String, constraint: String }, + /// A configured column or an expression saved in a configuration row. + Configuration { row: Option, column: String }, } impl ParamUser { @@ -647,6 +696,10 @@ impl ParamUser { ParamUser::Parameter { name } => format!("parameter {name}"), ParamUser::Feature { name, input, .. } => format!("{name} ({input})"), ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"), + ParamUser::Configuration { row, column } => match row { + Some(row) => format!("configuration `{row}` ({column})"), + None => format!("configuration column {column}"), + }, } } } @@ -716,6 +769,24 @@ impl Document { /// expressions refer to it, and the sketch dimensions it drives. pub fn param_users(&self, name: &str) -> Vec { let mut out = Vec::new(); + for rule in &self.configs.rules { + if rule.parameter_references().iter().any(|reference| reference == name) { + out.push(ParamUser::Configuration { row: None, column: format!("rule `{}`", rule.name) }); + } + } + for (index, column) in self.configs.columns.iter().enumerate() { + if let crate::config::Column::Param(target) = column { + let key = self.config_key(column); + if target == name { + out.push(ParamUser::Configuration { row: None, column: key.clone() }); + } + for row in &self.configs.rows { + if row.cells.get(index).is_some_and(|value| expr::references(value).iter().any(|reference| reference == name)) { + out.push(ParamUser::Configuration { row: Some(row.name.clone()), column: key.clone() }); + } + } + } + } for p in &self.params { if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) { out.push(ParamUser::Parameter { name: p.name.clone() }); @@ -828,6 +899,9 @@ impl Document { } } // Configuration cells refer to parameters too. + for rule in &mut self.configs.rules { + rule.rename_parameter(old, new); + } for r in &mut self.configs.rows { for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) { if matches!(column, crate::config::Column::Param(_)) { diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 6bee00c8..1d109626 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -16,6 +16,118 @@ use solvecraft_kernel::{self as kernel, Body, EdgeSpec, FaceSpec, SurfSpec}; use crate::expr::{self, Kind, Value}; use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector, mat_inverse, mat_mul}; +// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces. +// These are work limits, never geometry truncation: a larger input returns a useful error. +const MAX_FLAT_POINTS: usize = 16_384; +const MAX_FLAT_PIECES: usize = 4096; +const MAX_FLAT_WORK: usize = 500_000_000; + +fn flat_budget_error() -> DocError { + DocError::Invalid("sheet metal flat geometry exceeds the bounded processing budget".into()) +} + +pub(crate) struct FlatWork(usize); + +impl FlatWork { + pub(crate) fn new() -> Self { + Self(MAX_FLAT_WORK) + } + fn charge(&mut self, work: usize) -> Result<()> { + self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?; + Ok(()) + } +} + +fn flat_polyline(lp: &Loop2) -> Result> { + if lp.segs.len() > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + let mut points = Vec::new(); + for seg in &lp.segs { + let p = seg.polyline(1e-3); + let add = p.len().saturating_sub(1); + if points.len().saturating_add(add) > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + points.extend(p.iter().take(add)); + } + Ok(points) +} + +fn flat_points(polys: &[Vec]) -> Result { + let n = polys.iter().try_fold(0usize, |n, p| n.checked_add(p.len()).ok_or_else(flat_budget_error))?; + if n > MAX_FLAT_POINTS || polys.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + Ok(n) +} + +fn planar_region(region: &Region2) -> Result { + if region.holes.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + region_complexity(std::slice::from_ref(region))?; + let convert = |lp: &Loop2| -> Result { + if lp.segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) { + flat_polyline(lp)?; + Ok(lp.clone()) + } else { + // Keep free-form profiles through controlled 0.001 mm chord sampling. + Ok(Loop2::polygon(&flat_polyline(lp)?)) + } + }; + Ok(Region2 { + outer: convert(®ion.outer)?.ccw(), + holes: region.holes.iter().map(|h| convert(h).map(|l| l.ccw().reversed())).collect::>()?, + }) +} + +fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> { + if regions.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + let (mut edges, mut points) = (0usize, 0usize); + for lp in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) { + edges = edges.saturating_add(lp.segs.len()); + if edges > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + points = points.saturating_add(flat_polyline(lp)?.len()); + if points > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + } + Ok((edges, points)) +} + +fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result> { + let (ae, ap) = region_complexity(a)?; + let (be, bp) = region_complexity(b)?; + let edges = ae.saturating_add(be); + work.charge(edges.saturating_mul(edges).saturating_add(edges.saturating_mul(ap.saturating_add(bp))).saturating_mul(8))?; + let regions = kernel::planar_regions_boolean(a, b, op, 1e-7)?; + region_complexity(®ions)?; + Ok(regions) +} + +// Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not +// repeatedly arrange a growing accumulated boundary against just one small region. +fn material_union(regions: Vec, work: &mut FlatWork) -> Result> { + region_complexity(®ions)?; + let mut groups: Vec> = regions.into_iter().map(|r| vec![r]).collect(); + while groups.len() > 1 { + let mut next = Vec::new(); + let mut iter = groups.into_iter(); + while let Some(a) = iter.next() { + match iter.next() { + Some(b) => next.push(material_boolean(&a, &b, kernel::BoolOp::Union, work)?), + None => next.push(a), + } + } + groups = next; + } + Ok(groups.pop().unwrap_or_default()) +} /// A sheet metal rule. Values are expressions; `Thickness` inside them is the rule's thickness. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SheetRule { @@ -424,65 +536,70 @@ impl SheetBody { } } - /// The pieces of the sheet as cells: base and flange panels and bend zones, each less the - /// cut-outs over it (cut-outs inside a panel stay exact holes; ones crossing a piece's edge - /// must be convex polygons). - fn cells(&self) -> Result> { - let mut pieces: Vec<(Piece, Vec)> = - self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), dedup(&r.outer.ccw().polyline(1e-3)))).collect(); - for (i, f) in self.flanges.iter().enumerate() { - pieces.push((Piece::Zone(i), dedup(&self.zone(f).outer.ccw().polyline(1e-3)))); + /// The pieces of the sheet as cells: base and flange panels and bend zones, each less + /// the combined cut-out material. Planar clipping retains holes and disconnected islands. + fn cells_with_work(&self, work: &mut FlatWork) -> Result> { + if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); } + let raw = self + .flanges + .iter() + .fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len))) + .saturating_add(self.holes.iter().fold(0usize, |n, h| n.saturating_add(h.outer.segs.len()))); + if raw > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + let mut pieces: Vec<(Piece, Region2)> = self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), r)).collect(); + for (i, f) in self.flanges.iter().enumerate() { + pieces.push((Piece::Zone(i), self.zone(f))); + } + let mut cuts = Vec::new(); + let mut cut_points = 0usize; + for hole in &self.holes { + let region = planar_region(hole)?; + cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1); + if cut_points > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + cuts.push(region); + } + let cuts = material_union(cuts, work)?; let mut out = Vec::new(); - for (piece, poly) in pieces { - let mut inner: Vec> = Vec::new(); - let mut crossing: Vec> = Vec::new(); - for h in &self.holes { - let hp = dedup(&h.outer.ccw().polyline(1e-3)); - if matches!(piece, Piece::Panel(_)) && hp.iter().all(|p| point_in(&poly, *p)) { - inner.push(h.outer.ccw().reversed().segs.clone()); - } else if hp.iter().any(|p| point_in(&poly, *p)) || poly.iter().any(|p| point_in(&hp, *p)) || polys_cross(&poly, &hp) { - if !is_convex(&hp) { - return Err(DocError::Invalid( - "not supported yet: cut-outs over a panel's edge or a bend that are not convex polygons".into(), - )); - } - crossing.push(hp); + let mut points = 0usize; + for (piece, region) in pieces { + let panel = planar_region(®ion)?; + let material = if cuts.is_empty() { vec![panel] } else { material_boolean(&[panel], &cuts, kernel::BoolOp::Cut, work)? }; + for region in material { + points = points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1); + if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES { + return Err(flat_budget_error()); } - } - if crossing.is_empty() { - let outer = Loop2::polygon(&poly).segs; - out.push(Cell { - piece, - outer: if matches!(piece, Piece::Panel(None)) { self.base.outer.ccw().segs.clone() } else { outer }, - holes: inner, - }); - continue; - } - let mut parts = vec![poly]; - for h in &crossing { - parts = parts.iter().flat_map(|p| convex_diff(p, h)).collect(); - } - let loops = merge_loops(&parts); - let (outers, holes): (Vec>, Vec>) = loops.into_iter().partition(|l| signed_area(l) > 0.0); - for o in outers { - let mut hs: Vec> = - holes.iter().filter(|h| h.first().is_some_and(|p| point_in(&o, *p))).map(|h| Loop2::polygon(h).segs).collect(); - hs.extend(inner.iter().filter(|h| h.first().is_some_and(|s| point_in(&o, s.start()))).cloned()); - out.push(Cell { piece, outer: Loop2::polygon(&o).segs, holes: hs }); + out.push(Cell { piece, outer: region.outer.segs, holes: region.holes.into_iter().map(|l| l.segs).collect() }); } } Ok(out) } - /// Faces of the solid made by `cells` between heights `z0` and `z1`. - fn faces(&self, cells: &[Cell], z0: f64, z1: f64) -> Result> { + fn faces(&self, cells: &[Cell], z0: f64, z1: f64, work: &mut FlatWork) -> Result> { // Split straight edges where other cells' corners touch them, so neighbours share edges. + let edges = cells + .iter() + .fold(0usize, |n, c| n.saturating_add(c.outer.len()).saturating_add(c.holes.iter().fold(0usize, |m, h| m.saturating_add(h.len())))); + if edges > MAX_FLAT_POINTS || cells.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + work.charge(edges.saturating_mul(edges).saturating_mul(4))?; let corners: Vec = cells.iter().flat_map(|c| c.outer.iter().chain(c.holes.iter().flatten()).map(Seg2::start)).collect(); - let split_loop = |segs: &[Seg2]| -> Vec { + let mut split_edges = 0usize; + let mut split_loop = |segs: &[Seg2]| -> Result> { let mut v = Vec::new(); for e in segs { let Seg2::Line { a, b } = *e else { + split_edges += 1; + if split_edges > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } v.push(*e); continue; }; @@ -499,18 +616,31 @@ impl SheetBody { ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); let mut prev = a; for t in ts { + split_edges += 1; + if split_edges > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } let q = a + d * t; v.push(Seg2::Line { a: prev, b: q }); prev = q; } + split_edges += 1; + if split_edges > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } v.push(Seg2::Line { a: prev, b }); } - v + Ok(v) }; - let cells: Vec = cells - .iter() - .map(|c| Cell { piece: c.piece, outer: split_loop(&c.outer), holes: c.holes.iter().map(|h| split_loop(h)).collect() }) - .collect(); + let mut split_cells = Vec::new(); + for c in cells { + let outer = split_loop(&c.outer)?; + let holes = c.holes.iter().map(|h| split_loop(h)).collect::>()?; + split_cells.push(Cell { piece: c.piece, outer, holes }); + } + // Reserve the complete adjacency scan before visiting any wall. + work.charge(split_edges.saturating_mul(split_edges).saturating_mul(2))?; + let cells = split_cells; let near = |p: Vec2, q: Vec2| p.dist(q) < 1e-6; // Shared with a neighbour: the same flat edge the other way, landing in the same place // (flat neighbours across a corner of two bends do not). @@ -602,40 +732,78 @@ impl SheetBody { if self.flat { return self.flat_solid(); } - Ok(kernel::sew(&self.faces(&self.cells()?, 0.0, self.t)?)?) + let mut work = FlatWork::new(); + let cells = self.cells_with_work(&mut work)?; + Ok(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?) } /// The flat pattern as one extrusion (outline with its cut-outs). pub fn flat_solid(&self) -> Result { - let loops = self.outline()?; - let mut loops: Vec = loops.into_iter().map(|l| Loop2::polygon(&l)).collect(); - loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs())); - let mut it = loops.into_iter(); - let outer = it.next().ok_or_else(|| DocError::Invalid("empty flat pattern".into()))?.ccw(); - let mut holes: Vec = it.map(|l| l.ccw().reversed()).collect(); - let op = outer.polyline(1e-3); - for h in &self.holes { - let hp = h.outer.polyline(1e-3); - if hp.iter().all(|p| point_in(&op, *p)) { - holes.push(h.outer.ccw().reversed()); - } else { - return Err(DocError::Invalid("not supported yet: cut-outs over the flat pattern's edge".into())); - } - } + let regions = self.flat_regions()?; let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?; - kernel::extrude(&pl, &[Region2 { outer, holes }], 0.0, self.t)?.into_iter().next().ok_or_else(|| DocError::Invalid("flat pattern".into())) + Ok(kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?) } + /// Actual flat material boundaries after cut-outs. The same panel and cut regions build + /// the folded solid; planar unions remove panel/bend seams and turn edge-crossing cuts + /// into notches. Lines and circular arcs remain exact through clipping and merging. + pub fn flat_regions(&self) -> Result> { + let mut work = FlatWork::new(); + // Merge structural pieces before cutting: clipping each panel first introduces + // coincident notch endpoints at seams, while a single material cut has no seams. + if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES { + return Err(flat_budget_error()); + } + let raw = self.flanges.iter().fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len))); + if raw > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + let mut regions = Vec::new(); + let mut panel_points = 0usize; + for (_, r) in self.panels() { + let r = planar_region(&r)?; + panel_points = panel_points.saturating_add(region_complexity(std::slice::from_ref(&r))?.1); + if panel_points > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + regions.push(r); + } + for f in &self.flanges { + regions.push(planar_region(&self.zone(f))?); + region_complexity(®ions)?; + } + let regions = material_union(regions, &mut work)?; + let mut cuts = Vec::new(); + let mut cut_points = 0usize; + for hole in &self.holes { + let region = planar_region(hole)?; + cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1); + if cut_points > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + cuts.push(region); + } + let cuts = material_union(cuts, &mut work)?; + let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? }; + if regions.is_empty() { + return Err(DocError::Invalid("empty flat pattern".into())); + } + Ok(regions) + } /// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out. pub fn outline(&self) -> Result>> { - let mut polys: Vec> = vec![dedup(&self.base.outer.ccw().polyline(1e-3))]; - for f in &self.flanges { - polys.push(dedup(&self.zone(f).outer.ccw().polyline(1e-3))); - if f.leg > 1e-9 { - polys.push(dedup(&self.panel(f).outer.ccw().polyline(1e-3))); - } + if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES { + return Err(flat_budget_error()); } - let loops = merge_loops(&polys); + let mut polys: Vec> = vec![dedup(&flat_polyline(&self.base.outer.ccw())?)]; + for f in &self.flanges { + polys.push(dedup(&flat_polyline(&self.zone(f).outer.ccw())?)); + if f.leg > 1e-9 { + polys.push(dedup(&flat_polyline(&self.panel(f).outer.ccw())?)); + } + flat_points(&polys)?; + } + let loops = merge_loops(&polys, &mut FlatWork::new())?; if loops.is_empty() { return Err(DocError::Invalid("empty flat pattern".into())); } @@ -670,13 +838,13 @@ impl SheetBody { /// the sewn (faces, edges, vertices). pub fn round_hole_correction(&self) -> (i64, i64, i64) { let (mut df, mut de, mut dv) = (0i64, 0i64, 0i64); - for h in &self.holes { + let Ok(regions) = self.flat_regions() else { return (0, 0, 0) }; + for h in regions.iter().flat_map(|r| &r.holes) { let arcs: Vec<(Vec2, f64)> = - h.outer.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect(); - let n = h.outer.segs.len() as i64; - let full = !arcs.is_empty() - && arcs.len() == h.outer.segs.len() - && arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9); + h.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect(); + let n = h.segs.len() as i64; + let full = + !arcs.is_empty() && arcs.len() == h.segs.len() && arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9); if full && n > 1 { df += 1 - n; de += 2 - 3 * n; @@ -688,7 +856,7 @@ impl SheetBody { /// Bounding size of the flat pattern (x, y in the flat frame). pub fn flat_size(&self) -> Result<(f64, f64)> { - let loops = self.outline()?; + let loops: Vec> = self.flat_regions()?.iter().map(|r| r.outer.polyline(1e-3)).collect(); let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); for p in loops.iter().flatten() { lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); @@ -1114,8 +1282,9 @@ impl SheetBody { } /// Map a region in a world plane to flat coordinates through a panel's placement. - pub fn region_to_flat(&self, panel: Option, plane: &Plane, r: &Region2) -> Option { - let inv = mat_inverse(&self.world(panel))?; + pub fn region_to_flat(&self, panel: Option, plane: &Plane, r: &Region2) -> Result { + region_complexity(std::slice::from_ref(r))?; + let inv = mat_inverse(&self.world(panel)).ok_or_else(|| DocError::Invalid("invalid sheet panel frame".into()))?; let map = |p: Vec2| { let q = apply_point(&inv, plane.to_world(p)); Vec2::new(q.x, q.y) @@ -1138,16 +1307,18 @@ impl SheetBody { other => Seg2::Line { a: map(other.start()), b: map(other.end()) }, } }; - let curved_other = r.outer.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. })); - let mut lp = if curved_other { - Loop2::polygon(&r.outer.polyline(1e-3).into_iter().map(map).collect::>()) - } else { - Loop2 { segs: r.outer.segs.iter().map(seg).collect() } + let map_loop = |lp: &Loop2| -> Result { + let curved_other = lp.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. })); + if curved_other { + Ok(Loop2::polygon(&flat_polyline(lp)?.into_iter().map(map).collect::>())) + } else { + Ok(Loop2 { segs: lp.segs.iter().map(seg).collect() }) + } }; - if lp.signed_area() < 0.0 { - lp = lp.reversed(); - } - Some(Region2 { outer: lp, holes: Vec::new() }) + Ok(Region2 { + outer: map_loop(&r.outer)?.ccw(), + holes: r.holes.iter().map(|h| map_loop(h).map(|l| l.ccw().reversed())).collect::>()?, + }) } /// Panels whose plane is parallel to `plane` (world): (panel, region). @@ -1178,7 +1349,9 @@ fn point_in(poly: &[Vec2], p: Vec2) -> bool { /// The boundary of a union of counter-clockwise polygons that meet edge to edge: edges are /// split where corners touch them, edges running both ways cancel, the rest chain into loops /// (outer loops counter-clockwise, holes clockwise; straight runs merged). -fn merge_loops(polys: &[Vec]) -> Vec> { +fn merge_loops(polys: &[Vec], work: &mut FlatWork) -> Result>> { + let n = flat_points(polys)?; + work.charge(n.saturating_mul(n).saturating_mul(2))?; let mut segs: Vec<(Vec2, Vec2)> = Vec::new(); for p in polys { let m = p.len(); @@ -1208,9 +1381,13 @@ fn merge_loops(polys: &[Vec]) -> Vec> { ts.sort_by(|x, y| x.total_cmp(y)); ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); for w in ts.windows(2) { + if split.len() >= MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } split.push((*a + d * w[0], *a + d * w[1])); } } + work.charge(split.len().saturating_mul(split.len()).saturating_mul(2))?; let same = |p: Vec2, q: Vec2| p.dist(q) < 1e-6; let keep: Vec<(Vec2, Vec2)> = split .iter() @@ -1248,7 +1425,7 @@ fn merge_loops(polys: &[Vec]) -> Vec> { loops.push(clean); } } - loops + Ok(loops) } fn signed_area(p: &[Vec2]) -> f64 { @@ -1256,27 +1433,6 @@ fn signed_area(p: &[Vec2]) -> f64 { (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 } -/// A convex polygon minus a convex polygon (both counter-clockwise), as convex pieces. -fn convex_diff(a: &[Vec2], h: &[Vec2]) -> Vec> { - let mut out = Vec::new(); - let mut rest = a.to_vec(); - let m = h.len(); - for i in 0..m { - let (Some(&p), Some(&q)) = (h.get(i), h.get((i + 1) % m)) else { continue }; - let Some(d) = (q - p).normalized() else { continue }; - let left = Vec2::new(-d.y, d.x); - let outside = dedup(&clip(&rest, p, left * -1.0)); - if outside.len() >= 3 && signed_area(&outside).abs() > 1e-12 { - out.push(outside); - } - rest = dedup(&clip(&rest, p, left)); - if rest.len() < 3 { - break; - } - } - out -} - /// Do two polygons' edges cross? fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool { let seg = |p: &[Vec2], i: usize| -> Option<(Vec2, Vec2)> { Some((*p.get(i)?, *p.get((i + 1) % p.len())?)) }; @@ -1291,25 +1447,24 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool { }) } -fn is_convex(p: &[Vec2]) -> bool { - let m = p.len(); - (0..m).all(|i| match (p.get(i), p.get((i + 1) % m), p.get((i + 2) % m)) { - (Some(a), Some(b), Some(c)) => (*b - *a).cross(*c - *b) >= -1e-9, - _ => true, - }) +/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel. +pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result { + let (a, b) = (planar_region(a)?, planar_region(b)?); + Ok(!material_boolean(&[a], &[b], kernel::BoolOp::Intersect, work)?.is_empty()) } /// A sheet from a closed profile (base flange): the region in `plane`, thickness along its normal. pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region: Region2) -> SheetBody { + let holes = region.holes.into_iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect(); SheetBody { body: body.into(), rule: rule.into(), t: rv.thickness, k: rv.k_factor, frame: plane, - base: region, + base: Region2 { outer: region.outer, holes: Vec::new() }, flanges: Vec::new(), - holes: Vec::new(), + holes, flat: false, gap: rv.gap, base_curves: Vec::new(), @@ -1510,4 +1665,119 @@ mod tests { s.flat = true; assert!(s.solid().is_ok()); } + + #[test] + fn disconnected_material_keeps_each_hole_with_its_own_region() { + let mut s = plate(100.0, 60.0); + s.holes.push(rect(Vec2::new(45.0, -5.0), Vec2::new(55.0, -5.0), Vec2::new(55.0, 65.0), Vec2::new(45.0, 65.0))); + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] }); + let regions = s.flat_regions().unwrap(); + assert_eq!(regions.len(), 2); + let left = regions.iter().find(|r| r.outer.contains(Vec2::new(10.0, 10.0))).unwrap(); + let right = regions.iter().find(|r| r.outer.contains(Vec2::new(90.0, 10.0))).unwrap(); + assert_eq!(left.holes.len(), 1); + assert!(left.holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); + assert!(right.holes.is_empty()); + let area: f64 = regions.iter().map(Region2::area).sum(); + assert!((area - (5400.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6); + assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1); + } + + #[test] + fn ring_cut_retains_exact_central_material_island() { + let mut s = plate(100.0, 60.0); + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 10.0), holes: vec![Loop2::circle(Vec2::new(50.0, 30.0), 5.0)] }); + let regions = s.flat_regions().unwrap(); + assert_eq!(regions.len(), 2); + let island = regions.iter().find(|r| r.outer.contains(Vec2::new(50.0, 30.0))).unwrap(); + assert!(island.holes.is_empty()); + assert!(island.outer.segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); + let area: f64 = regions.iter().map(Region2::area).sum(); + assert!((area - (6000.0 - std::f64::consts::PI * 75.0)).abs() < 1e-6); + assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1); + } + + #[test] + fn freeform_profile_uses_bounded_sampling_with_exact_round_holes() { + let mut s = plate(100.0, 60.0); + s.base.outer.segs = vec![ + Seg2::Line { a: Vec2::ZERO, b: Vec2::new(100.0, 0.0) }, + Seg2::Line { a: Vec2::new(100.0, 0.0), b: Vec2::new(100.0, 60.0) }, + Seg2::Cubic { p0: Vec2::new(100.0, 60.0), p1: Vec2::new(200.0 / 3.0, 80.0), p2: Vec2::new(100.0 / 3.0, 80.0), p3: Vec2::new(0.0, 60.0) }, + Seg2::Line { a: Vec2::new(0.0, 60.0), b: Vec2::ZERO }, + ]; + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 3.0), holes: vec![] }); + let regions = s.flat_regions().unwrap(); + assert!(regions[0].outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. }))); + assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))); + // The cubic has x=100(1-t), y=60+60t(1-t), hence an exact blank area of 7000. + let expected = (7000.0 - std::f64::consts::PI * 9.0) * 2.5; + assert!((vol(&s.solid().unwrap()) - expected).abs() < 0.5); + assert!((vol(&s.flat_solid().unwrap()) - expected).abs() < 0.5); + } + + #[test] + fn flat_geometry_budgets_reject_aggregate_and_intermediate_work() { + let mut s = plate(100.0, 60.0); + // Each arc is individually legal, but their combined sampled points exceed the cap. + s.base.outer.segs = vec![Seg2::Arc { center: Vec2::ZERO, radius: 1000.0, start: 0.0, sweep: std::f64::consts::TAU }; 8]; + assert!(s.flat_regions().unwrap_err().to_string().contains("budget")); + let mut s = plate(100.0, 60.0); + s.holes = vec![Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 1.0), holes: vec![] }; MAX_FLAT_PIECES + 1]; + assert!(s.flat_regions().unwrap_err().to_string().contains("budget")); + let p = vec![vec![Vec2::ZERO; MAX_FLAT_POINTS]]; + assert!(merge_loops(&p, &mut FlatWork::new()).unwrap_err().to_string().contains("budget")); + // Exhausting a clipping/merge allowance must report an error before work begins. + assert!(merge_loops(&[vec![Vec2::ZERO, Vec2::X, Vec2::Y]], &mut FlatWork(0)).is_err()); + } + + #[test] + fn overlapping_round_cutouts_union_with_exact_arc_boundaries() { + let mut s = plate(100.0, 60.0); + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] }); + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(22.0, 30.0), 5.0), holes: vec![] }); + let regions = s.flat_regions().unwrap(); + assert_eq!(regions[0].holes.len(), 1); + assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); + let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt(); + let area = 6000.0 - (50.0 * std::f64::consts::PI - intersection); + assert!((regions[0].area() - area).abs() < 1e-6); + assert!((vol(&s.solid().unwrap()) - area * 2.5).abs() < 0.1); + } + + #[test] + fn nested_and_repeated_round_cutouts_keep_only_the_largest_exact_boundary() { + for reverse in [false, true] { + let mut s = plate(100.0, 60.0); + let mut radii = vec![3.0, 5.0, 5.0]; + if reverse { + radii.reverse(); + } + for radius in radii { + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), radius), holes: vec![] }); + } + let regions = s.flat_regions().unwrap(); + assert_eq!(regions[0].holes.len(), 1); + assert!((regions[0].area() - (6000.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6); + let volume = vol(&s.solid().unwrap()); + // Body measure tessellates curved faces; the exact region area above is the + // stronger analytic check, while this follows the existing sheet measure budget. + assert!((volume - (6000.0 - std::f64::consts::PI * 25.0) * 2.5).abs() < 0.1, "{volume}"); + assert_eq!(s.round_hole_correction(), (-1, -4, -2)); + } + } + + #[test] + fn sewing_budget_rejects_edge_splitting_growth_before_allocating_faces() { + let s = plate(100.0, 60.0); + let cells: Vec = (0..250) + .map(|i| Cell { + piece: Piece::Panel(None), + outer: Loop2::polygon(&[Vec2::new(i as f64, 0.0), Vec2::new(250.0, 0.0), Vec2::new(i as f64, 1.0)]).segs, + holes: vec![], + }) + .collect(); + assert!(s.faces(&cells, 0.0, 2.5, &mut FlatWork::new()).unwrap_err().to_string().contains("budget")); + assert!(s.faces(&cells[..1], 0.0, 2.5, &mut FlatWork(0)).unwrap_err().to_string().contains("budget")); + } } diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 3f906def..008dc4ac 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"), CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params( "visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \ - or folder?: bodies|sketches|construction|origin (all of it). View state: not saved, not an undo step", + or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). View state: not saved, not an undo step", ), ]; @@ -191,6 +191,66 @@ fn shown_item(s: &Session, key: &str) -> Option { fn visibility(s: &mut Session, p: &Value) -> Result { let cmd = "browser.visibility"; let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?; + if let Some(value) = p.get("paths") { + if p.get("items").is_some() || p.get("folder").is_some() { + return Err(bad(cmd, "give paths, items or folder, not several")); + } + let paths = + value.as_array().filter(|a| !a.is_empty() && a.len() <= 10_000).ok_or_else(|| bad(cmd, "paths must list 1..10000 occurrence paths"))?; + let paths: Vec> = paths + .iter() + .map(|value| { + let path = value + .as_array() + .filter(|a| !a.is_empty() && a.len() <= 64) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .ok_or_else(|| bad(cmd, "each path must list 1..64 occurrence ids"))?; + s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?; + Ok(path) + }) + .collect::>()?; + if bool_(p, "isolate").unwrap_or(false) { + if !visible { + return Err(bad(cmd, "isolate requires visible:true")); + } + let mut hidden = Vec::new(); + let mut ancestor_bodies = Vec::new(); + let mut stack = vec![Vec::::new()]; + let mut count = 0; + while let Some(parent) = stack.pop() { + for child in s.occurrence_children(&parent)? { + count += 1; + if count > 100_000 { + return Err(bad(cmd, "isolate exceeds 100000 placements")); + } + if paths.iter().any(|selected| child.path.starts_with(selected)) { + continue; + } + if paths.iter().any(|selected| selected.starts_with(&child.path)) { + ancestor_bodies.extend(child.bodies.into_iter().map(|b| b.world)); + stack.push(child.path); + } else { + hidden.push(child.path); + } + } + } + let mut root_bodies: Vec = + s.model.state().bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == 0).map(|b| b.name.clone()).collect(); + root_bodies.extend(ancestor_bodies); + s.visibility.hidden_occurrences = hidden; + s.visibility.hidden_bodies = root_bodies; + s.revision += 1; + return Ok(json!({"visible":true,"isolated":paths,"visibility":s.visibility})); + } + for path in &paths { + s.visibility.hidden_occurrences.retain(|p| p != path); + if !visible { + s.visibility.hidden_occurrences.push(path.clone()); + } + } + s.revision += 1; + return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility})); + } let items: Vec = match (str_(p, "folder"), p.get("items")) { (Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")), (Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(), @@ -211,6 +271,9 @@ fn visibility(s: &mut Session, p: &Value) -> Result { (None, None) => return Err(bad(cmd, "`items` or `folder` is required")), }; let v = &mut s.visibility; + if visible && str_(p, "folder") == Some("bodies") { + v.hidden_occurrences.clear(); + } for it in &items { match it { Shown::Sketch(id) => { @@ -247,6 +310,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "units": u, "kept": [] })); } // What the geometry reads: every feature's length inputs. + let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten(); let before = s.doc.length_inputs(); s.doc_mut().units = u.to_string(); // Unit-less user parameters holding a bare number read it in the design's units where they @@ -278,6 +342,10 @@ fn document_units(s: &mut Session, p: &Value) -> Result { } } } + s.doc_mut().preserve_configuration_units(&old, &kept); + if let Some(row) = matched_configuration { + s.doc_mut().apply_configuration(&row)?; + } Ok(json!({ "units": u, "kept": kept })) } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 193c896f..f7455944 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -8,21 +8,26 @@ use solvecraft_doc::{FeatureKind, Mat, Occurrence}; use solvecraft_geom::Vec3; use super::CommandSpec; -use crate::params::{bad, bool_, num, str_, string_list, vec3}; +use crate::params::{bad, bool_, str_, string_list, vec3}; use crate::{Result, Session}; #[path = "component_properties.rs"] mod properties; +#[cfg(test)] +#[path = "occurrence_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod occurrence_tests; + pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.create", "New Component", new_component) .at("SOLID", "ASSEMBLE") .icon("component") - .params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"), + .params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true)"), CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies) .icon("component") .params("bodies: [names] → one new component per body, named after it"), - CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"), + CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"), CommandSpec::new("component.list", "List Components", list).noundo(), CommandSpec::new("component.properties", "Component Physical Properties", properties::component) .icon("measure").noundo() @@ -35,13 +40,13 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"), CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"), CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence) - .params("component (or occurrence): what to place again; parent?: component (default: the same parent); translate?: [x,y,z]"), - CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its features into a new, independent component; translate?: [x,y,z]"), + .params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"), + CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its own features into a new, independent component; occurrence?, path?: selected source placement; translate?: [x,y,z]"), CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence) - .params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"), + .params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"), CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"), CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"), - CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"), + CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"), ]; fn key(v: Option<&Value>) -> Option { @@ -66,12 +71,61 @@ fn translation(t: Vec3) -> Mat { solvecraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0) } +fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result { + let v = match p.get(key) { + None => default, + Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?, + }; + if !v.is_finite() || (coordinate && [v.x, v.y, v.z].iter().any(|x| x.abs() > 1e9)) { + return Err(bad(cmd, format!("{key} exceeds supported numeric range"))); + } + Ok(v) +} + +fn valid_position(m: &Mat) -> bool { + let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]); + let (x, y, z) = (column(0), column(1), column(2)); + m.iter().flatten().all(|x| x.is_finite()) + && m[3][..3].iter().all(|x| x.abs() <= 1e9) + && [x, y, z].iter().all(|axis| (axis.dot(*axis) - 1.0).abs() < 1e-6) + && x.dot(y).abs() < 1e-6 + && x.dot(z).abs() < 1e-6 + && y.dot(z).abs() < 1e-6 + && x.cross(y).dot(z) > 0.0 + && [m[0][3], m[1][3], m[2][3]].iter().all(|x| x.abs() < 1e-9) + && (m[3][3] - 1.0).abs() < 1e-9 +} + fn new_component(s: &mut Session, p: &Value) -> Result { let cmd = "component.create"; let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component }; + let parent_path = if let Some(value) = p.get("parent_path") { + let path = value + .as_array() + .filter(|a| a.len() < 64) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .ok_or_else(|| bad(cmd, "parent_path must list up to 63 occurrence ids"))?; + let component = if path.is_empty() { 0 } else { s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?.component }; + if component != parent { + return Err(bad(cmd, "parent_path belongs to another component")); + } + path + } else if parent == s.active_component { + s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(parent)) + } else { + s.doc.first_path(parent) + }; + if parent_path.len() >= 64 { + return Err(bad(cmd, "new component exceeds occurrence depth limit")); + } let id = s.doc_mut().add_component(str_(p, "name"), parent)?; if p.get("activate").and_then(Value::as_bool).unwrap_or(true) { + let mut path = parent_path; + if let Some(o) = s.doc.occurrence_of(id) { + path.push(o.id); + } s.active_component = id; + s.active_occurrence_path = Some(path); } let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default(); let occ = s.doc.occurrence_of(id).map(|o| o.id); @@ -102,7 +156,18 @@ fn from_bodies(s: &mut Session, p: &Value) -> Result { fn activate(s: &mut Session, p: &Value) -> Result { let id = component(s, p, "component", "component.activate")?; + let path = if p.get("path").is_some() || p.get("occurrence").is_some() { + let occurrence = occurrence(s, p, "component.activate")?; + let path = occurrence_path(s, p, occurrence, "component.activate")?; + if s.occurrence_placement(&path)?.component != id { + return Err(bad("component.activate", "path belongs to another component")); + } + Some(path) + } else { + None + }; s.active_component = id; + s.active_occurrence_path = path; s.revision += 1; Ok(json!({ "active": id })) } @@ -231,16 +296,30 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result { fn copy_occurrence(s: &mut Session, p: &Value) -> Result { let cmd = "occurrence.copy"; - let comp = if p.get("occurrence").is_some() { + let selected = if p.get("occurrence").is_some() { let o = occurrence(s, p, cmd)?; - s.doc.occurrences.iter().find(|x| x.id == o).map(|x| x.component).unwrap_or(0) + Some(o) + } else { + None + }; + let comp = if let Some(id) = selected { + s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.component).unwrap_or(0) } else { component(s, p, "component", cmd)? }; - let src = s.doc.occurrence_of(comp).cloned().ok_or_else(|| bad(cmd, "that component has no occurrence"))?; + let src = if let Some(id) = selected { s.doc.occurrences.iter().find(|o| o.id == id) } else { s.doc.occurrence_of(comp) } + .cloned() + .ok_or_else(|| bad(cmd, "that component has no occurrence"))?; + if p.get("path").is_some() { + occurrence_path(s, p, src.id, cmd)?; + } let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { src.parent }; - let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO); - let m = solvecraft_doc::mat_mul(&translation(t), &src.transform); + let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?; + let base = s.pending_moves.get(&src.id).unwrap_or(&src.transform); + let m = solvecraft_doc::mat_mul(&translation(t), base); + if !valid_position(&m) { + return Err(bad(cmd, "copy transform exceeds supported numeric range")); + } let id = s.doc_mut().add_occurrence(comp, parent, m)?; Ok(json!({"occurrence": id, "component": comp})) } @@ -252,8 +331,27 @@ fn paste_new(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "pick a component, not the design")); } let src = s.doc.components.iter().find(|c| c.id == comp).cloned().ok_or_else(|| bad(cmd, "component"))?; - let occ = s.doc.occurrence_of(comp).cloned(); - let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), src.parent)?; + let occ = if p.get("occurrence").is_some() { + let id = occurrence(s, p, cmd)?; + if p.get("path").is_some() { + occurrence_path(s, p, id, cmd)?; + } + let occurrence = s.doc.occurrences.iter().find(|o| o.id == id).cloned().ok_or_else(|| bad(cmd, "no occurrence"))?; + if occurrence.component != comp { + return Err(bad(cmd, "occurrence belongs to another component")); + } + Some(occurrence) + } else { + s.doc.occurrence_of(comp).cloned() + }; + let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?; + let base = occ.as_ref().map(|o| s.pending_moves.get(&o.id).copied().unwrap_or(o.transform)).unwrap_or(solvecraft_doc::IDENTITY); + let transform = solvecraft_doc::mat_mul(&translation(t), &base); + if !valid_position(&transform) { + return Err(bad(cmd, "copy transform exceeds supported numeric range")); + } + let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent); + let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), parent)?; // Copy the component's features with fresh ids and names; references between them follow. let feats: Vec = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect(); let mut idmap: std::collections::HashMap = Default::default(); @@ -276,10 +374,8 @@ fn paste_new(s: &mut Session, p: &Value) -> Result { slot.component = new_id; } } - let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO); - let base = occ.map(|o| o.transform).unwrap_or(solvecraft_doc::IDENTITY); if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) { - o.transform = solvecraft_doc::mat_mul(&translation(t), &base); + o.transform = transform; } Ok(json!({"component": new_id, "features": new_ids})) } @@ -316,6 +412,34 @@ fn remap(v: &mut Value, ids: &std::collections::HashMap, names: &std:: } } +pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result> { + let path = if let Some(value) = p.get("path") { + value + .as_array() + .filter(|a| !a.is_empty() && a.len() <= 64) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .ok_or_else(|| bad(cmd, "`path` must list 1..64 occurrence ids"))? + } else { + let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?; + let mut path = s.doc.first_path(o.parent); + // A world frame requires an explicit containing placement when any ancestor is instanced. + for ancestor in &path { + if let Some(a) = s.doc.occurrences.iter().find(|a| a.id == *ancestor) + && s.doc.occurrences.iter().filter(|x| x.component == a.component).count() > 1 + { + return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")); + } + } + path.push(id); + path + }; + if path.last() != Some(&id) { + return Err(bad(cmd, "path and occurrence disagree")); + } + s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?; + Ok(path) +} + fn move_occurrence(s: &mut Session, p: &Value) -> Result { let cmd = "occurrence.move"; let id = occurrence(s, p, cmd)?; @@ -323,19 +447,56 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result { if o.grounded { return Err(bad(cmd, format!("{} is grounded", o.name))); } - let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO); - let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z); + let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?; + let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?; let angle = match p.get("angle") { Some(Value::String(e)) => s.doc.eval(e, solvecraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?, - _ => num(p, "angle").unwrap_or(0.0), + None => 0.0, + Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?, }; - if !(t.is_finite() && angle.is_finite()) { + if !(t.is_finite() && axis.is_finite() && angle.is_finite()) { return Err(bad(cmd, "non-finite move")); } - let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); + if angle != 0.0 && axis.normalized().is_none() { + return Err(bad(cmd, "rotation axis must have a finite nonzero length")); + } + let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?; let delta = solvecraft_doc::rigid(t, origin, axis, angle); - let cur = s.pending_moves.get(&id).copied().unwrap_or(o.transform); + let space = match p.get("space") { + None => "local", + Some(Value::String(space)) => space.as_str(), + _ => return Err(bad(cmd, "space must be local or world")), + }; + if !matches!(space, "local" | "world") { + return Err(bad(cmd, "space must be local or world")); + } + let path = if p.get("path").is_some() || space == "world" { Some(occurrence_path(s, p, id, cmd)?) } else { None }; + let delta = if space == "world" { + let placement = s.occurrence_placement(path.as_deref().unwrap_or(&[]))?; + let inverse = solvecraft_doc::mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?; + solvecraft_doc::mat_mul(&solvecraft_doc::mat_mul(&inverse, &delta), &placement.parent_world) + } else { + delta + }; + let cur = if let Some(value) = p.get("base_transform") { + let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?; + if !valid_position(&base) + || base[0][3].abs() > 1e-12 + || base[1][3].abs() > 1e-12 + || base[2][3].abs() > 1e-12 + || (base[3][3] - 1.0).abs() > 1e-12 + || solvecraft_doc::mat_inverse(&base).is_none() + { + return Err(bad(cmd, "base_transform must be finite, affine and invertible")); + } + base + } else { + s.pending_moves.get(&id).copied().unwrap_or(o.transform) + }; let next = solvecraft_doc::mat_mul(&delta, &cur); + if !valid_position(&next) { + return Err(bad(cmd, "move transform exceeds supported numeric range")); + } if bool_(p, "capture").unwrap_or(true) { s.pending_moves.remove(&id); if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) { @@ -351,6 +512,9 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result { fn ground(s: &mut Session, p: &Value) -> Result { let cmd = "occurrence.ground"; let id = occurrence(s, p, cmd)?; + if p.get("path").is_some() { + occurrence_path(s, p, id, cmd)?; + } let want = bool_(p, "grounded"); let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?; o.grounded = want.unwrap_or(!o.grounded); @@ -370,9 +534,24 @@ fn capture(s: &mut Session, _p: &Value) -> Result { Ok(json!({ "captured": n })) } -fn revert(s: &mut Session, _p: &Value) -> Result { - let n = s.pending_moves.len(); - s.pending_moves.clear(); +fn revert(s: &mut Session, p: &Value) -> Result { + let before = s.pending_moves.len(); + if let Some(value) = p.get("occurrences") { + let ids = value + .as_array() + .filter(|a| !a.is_empty() && a.len() <= 10_000) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .ok_or_else(|| bad("component.revert_position", "occurrences must list 1..10000 ids"))?; + if ids.iter().any(|id| !s.doc.occurrences.iter().any(|o| o.id == *id)) { + return Err(bad("component.revert_position", "no occurrence")); + } + for id in ids { + s.pending_moves.remove(&id); + } + } else { + s.pending_moves.clear(); + } + let n = before - s.pending_moves.len(); s.revision += 1; Ok(json!({ "reverted": n })) } @@ -383,7 +562,7 @@ pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) { if s.active_component == 0 || crate::frames::mapped() { return; } - let m = s.doc.component_transform(s.active_component); + let m = s.active_component_frame(); if solvecraft_doc::is_identity(&m) { return; } diff --git a/crates/engine/src/cmd/component_properties.rs b/crates/engine/src/cmd/component_properties.rs index bef6410b..c642e85c 100644 --- a/crates/engine/src/cmd/component_properties.rs +++ b/crates/engine/src/cmd/component_properties.rs @@ -64,18 +64,11 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result { let cmd = "occurrence.properties"; let id = super::occurrence(s, p, cmd)?; let doc = placed_doc(s); - let paths: Vec> = placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect(); - let path = if let Some(value) = p.get("path") { - let path = value - .as_array() - .filter(|a| !a.is_empty() && a.len() <= 64) - .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) - .ok_or_else(|| bad(cmd, "`path` must list 1…64 occurrence ids"))?; - if !paths.contains(&path) { - return Err(bad(cmd, "`path` does not identify a placement of this occurrence")); - } - path + let path = if p.get("path").is_some() { + super::occurrence_path(s, p, id, cmd)? } else { + let paths: Vec> = + placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect(); match paths.as_slice() { [path] => path.clone(), [] => return Err(bad(cmd, "occurrence has no placement")), @@ -97,9 +90,27 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, (Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)); let (mut faces, mut edges, mut vertices) = (0, 0, 0); let mut placement_count = 0; - for (component, path, transform) in - placements(doc, cmd)?.into_iter().filter(|(_, path, _)| if descendants { path.starts_with(selected) } else { path == selected }) - { + let selected_placements = if selected.is_empty() { + if descendants { placements(doc, cmd)? } else { vec![(0, Vec::new(), solvecraft_doc::IDENTITY)] } + } else { + let placement = s.occurrence_placement(selected).map_err(|e| bad(cmd, e.to_string()))?; + let mut out = vec![(placement.component, selected.to_vec(), placement.world)]; + let mut stack = if descendants { out.clone() } else { Vec::new() }; + while let Some((component, path, transform)) = stack.pop() { + for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) { + if path.len() >= 64 || out.len() >= 100_000 { + return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)")); + } + let mut child = path.clone(); + child.push(occurrence.id); + let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform); + out.push((occurrence.component, child.clone(), world)); + stack.push((occurrence.component, child, world)); + } + } + out + }; + for (component, path, transform) in selected_placements { placement_count += 1; for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) { let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform); diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index 334e2bf8..993f1b9b 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -22,6 +22,9 @@ pub static COMMANDS: &[CommandSpec] = &[ "name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool", ), CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"), + CommandSpec::new("config.preview", "Preview Configuration", preview).noundo().params("row: name — validates a rebuild without changing the design or history"), + CommandSpec::new("config.rule", "Configuration Rule", rule).params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"), + CommandSpec::new("config.rules", "Configuration Rules", show).noundo().params("ordered typed rules, rows and active state"), ]; fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { @@ -99,7 +102,7 @@ fn show(s: &mut Session, _p: &Value) -> Result { .iter() .map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::>()})) .collect(); - Ok(json!({"columns": keys, "rows": rows, "active": t.active, "matches": s.doc.configuration_matches()})) + Ok(json!({"columns": keys, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches()})) } fn column(s: &mut Session, p: &Value) -> Result { @@ -117,6 +120,9 @@ fn column(s: &mut Session, p: &Value) -> Result { } else { add_column(s, c, cmd)?; } + if !s.doc.configs.rules.is_empty() { + validate_rows(s, cmd)?; + } show(s, p) } @@ -126,6 +132,9 @@ fn row(s: &mut Session, p: &Value) -> Result { let idx = s.doc.configs.rows.iter().position(|r| r.name == name); if bool_(p, "delete").unwrap_or(false) { let i = idx.ok_or_else(|| bad(cmd, format!("no row `{name}`")))?; + if s.doc.configs.rows.len() == 1 && !s.doc.configs.rules.is_empty() { + return Err(bad(cmd, "delete rules before deleting the last configuration row")); + } let t = &mut s.doc_mut().configs; t.rows.remove(i); if t.active.as_deref() == Some(name.as_str()) { @@ -190,6 +199,7 @@ fn row(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "configuration name already exists")); } let t = &mut s.doc_mut().configs; + let validated_name = rename.clone().unwrap_or_else(|| name.clone()); match idx { Some(i) => { if let Some(r) = t.rows.get_mut(i) { @@ -209,17 +219,104 @@ fn row(s: &mut Session, p: &Value) -> Result { t.rows.push(ConfigRow { name, cells }); } } + if !s.doc.configs.rules.is_empty() { + let mut trial = s.scratch(); + activate(&mut trial, &json!({"row":validated_name})).map_err(|e| bad(cmd, e.to_string()))?; + } + show(s, p) +} + +fn validate_rows(s: &Session, cmd: &str) -> Result<()> { + if !s.doc.configs.rules.is_empty() && s.doc.configs.rows.is_empty() { + return Err(bad(cmd, "rules require at least one configuration row")); + } + for row in &s.doc.configs.rows { + let mut trial = s.scratch(); + activate(&mut trial, &json!({"row":row.name})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?; + } + Ok(()) +} + +fn rule(s: &mut Session, p: &Value) -> Result { + let cmd = "config.rule"; + if let Some(value) = p.get("rule") { + solvecraft_doc::config::validate_rules_json(&json!({"configs":{"rules":[value]}})).map_err(|e| bad(cmd, e.to_string()))?; + let mut rule: solvecraft_doc::config::ConfigRule = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, e.to_string()))?; + if rule.length_unit.is_none() { + rule.length_unit = s + .doc + .configs + .rules + .iter() + .find(|existing| existing.id == rule.id) + .and_then(|existing| existing.length_unit.clone()) + .or_else(|| Some(s.doc.units.clone())); + } + let rules = &mut s.doc_mut().configs.rules; + if let Some(index) = rules.iter().position(|r| r.id == rule.id) { + rules[index] = rule; + } else { + if rules.len() >= solvecraft_doc::config::MAX_RULES { + return Err(bad(cmd, "too many rules")); + } + rules.push(rule); + } + } else { + let id = str_(p, "id").ok_or_else(|| bad(cmd, "give rule or id"))?; + let rules = &mut s.doc_mut().configs.rules; + let index = rules.iter().position(|r| r.id == id).ok_or_else(|| bad(cmd, "unknown rule"))?; + if bool_(p, "delete").unwrap_or(false) { + rules.remove(index); + } else { + let position = p + .get("index") + .and_then(Value::as_u64) + .and_then(|v| usize::try_from(v).ok()) + .filter(|v| *v < rules.len()) + .ok_or_else(|| bad(cmd, "valid index required"))?; + let rule = rules.remove(index); + rules.insert(position, rule); + } + } + validate_rows(s, cmd)?; show(s, p) } fn activate(s: &mut Session, p: &Value) -> Result { let cmd = "config.activate"; let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string(); - s.doc_mut().apply_configuration(&r).map_err(|e| bad(cmd, e.to_string()))?; + let mut trial = s.scratch(); + let trace = trial.doc_mut().apply_configuration_with_trace(&r).map_err(|e| bad(cmd, e.to_string()))?; + trial.refresh(); + let new_errors: Vec = trial + .model + .results + .iter() + .filter_map(|result| { + result + .error + .as_ref() + .filter(|error| s.model.result(result.id).is_none_or(|before| before.error.as_ref() != Some(*error))) + .map(|error| format!("{}: {error}", result.name)) + }) + .collect(); + if !new_errors.is_empty() { + return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; ")))); + } + s.doc = trial.doc; s.refresh(); let errors: Vec = s.model.results.iter().filter_map(|x| x.error.clone()).collect(); let mut out = show(s, p)?; out["errors"] = json!(errors); + out["trace"] = json!(trace); + Ok(out) +} + +fn preview(s: &mut Session, p: &Value) -> Result { + let mut trial = s.scratch(); + let mut out = activate(&mut trial, p)?; + out["preview"] = json!(true); + out["parameters"] = json!(trial.doc.param_rows()); Ok(out) } @@ -240,6 +337,186 @@ mod tests { run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) } + #[test] + fn configuration_activation_tolerates_identical_existing_errors_but_rejects_reenabled_failure() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name":"radius","expression":"10 mm"})); + run(&mut s, "solid.sphere", json!({"radius":"radius"})); + let feature = s.doc.features[0].name.clone(); + run(&mut s, "config.configure", json!({"params":["radius"],"features":[feature]})); + run(&mut s, "config.row", json!({"name":"Existing error","values":{"radius":"-1 mm"}})); + run(&mut s, "parameters.change", json!({"name":"radius","expression":"-1 mm"})); + assert!(s.model.results[0].error.is_some()); + let existing = run(&mut s, "config.activate", json!({"row":"Existing error"})); + assert_eq!(existing["errors"].as_array().unwrap().len(), 1); + s.doc_mut().features[0].suppressed = true; + s.refresh(); + assert!(s.model.results[0].error.is_none()); + run(&mut s, "config.row", json!({"name":"Reenabled failure","values":{"radius":"-1 mm",format!("suppress:{feature}"):false}})); + let before = s.doc.clone(); + assert!(s.execute("config.activate", &json!({"row":"Reenabled failure"})).is_err()); + assert_eq!(s.doc, before); + assert!(s.model.results[0].error.is_none()); + } + + #[test] + fn configuration_rows_and_literal_rule_thresholds_keep_physical_units_on_switch() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); + let length = s.doc.features[0].param_names[0].clone(); + run(&mut s, "config.configure", json!({"params":[length]})); + run( + &mut s, + "config.rule", + json!({"rule":{"id":"limit","name":"Metric stock","condition":{"type":"parameter","parameter":length,"operator":"le","expression":"20"},"effect":{"type":"parameter_range","parameter":length,"minimum":"5","maximum":"20"}}}), + ); + run(&mut s, "document.units", json!({"units":"in"})); + assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm"); + let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap(); + assert_eq!(rule["condition"]["expression"], "20 mm"); + assert_eq!(rule["effect"]["minimum"], "5 mm"); + let preview = run(&mut s, "config.preview", json!({"row":"Default"})); + assert_eq!(preview["trace"][0]["matched"], true); + run(&mut s, "config.activate", json!({"row":"Default"})); + assert!((volume(&mut s) - 1000.0).abs() < 1e-6); + assert!(s.doc.configuration_matches()); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.units, "mm"); + assert_eq!(s.doc.configs.rows[0].cells[0], "10"); + } + + #[test] + fn configuration_mixed_rule_expressions_keep_authoring_units_without_rewriting_source() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":20,"height":10})); + let length = s.doc.features[0].param_names[0].clone(); + let width = s.doc.features[0].param_names[1].clone(); + run(&mut s, "config.configure", json!({"params":[length,width]})); + let expression = format!("{width} + 1"); + run( + &mut s, + "config.rule", + json!({"rule":{"id":"drive","name":"Stock extra","condition":{"type":"always"},"effect":{"type":"drive_parameter","parameter":length,"expression":expression}}}), + ); + run( + &mut s, + "config.rule", + json!({"rule":{"id":"range","name":"Stock bound","condition":{"type":"parameter","parameter":length,"operator":"lt","expression":format!("{width} + 2")},"effect":{"type":"parameter_range","parameter":length,"minimum":width,"maximum":format!("{width} + 2")}}}), + ); + run(&mut s, "config.activate", json!({"row":"Default"})); + let before = volume(&mut s); + assert!((before - 4200.0).abs() < 1e-6); + run(&mut s, "document.units", json!({"units":"in"})); + assert!((volume(&mut s) - before).abs() < 1e-6, "matched row rederives drives in their authoring unit"); + assert!(s.doc.configuration_matches()); + let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap(); + assert_eq!(rule["effect"]["expression"], expression); + assert_eq!(rule["length_unit"], "mm"); + let preview = run(&mut s, "config.preview", json!({"row":"Default"})); + assert_eq!(preview["trace"][1]["matched"], true); + } + + #[test] + fn configuration_rule_edits_enforce_existing_rows_and_are_undoable() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name":"length","expression":"10 mm"})); + run(&mut s, "solid.box", json!({"length":"length","width":10,"height":10})); + run(&mut s, "config.configure", json!({"params":["length"]})); + let rule = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"5 mm","maximum":"20 mm"}}); + run(&mut s, "config.rule", json!({"rule":rule})); + let before = s.doc.clone(); + assert!(s.execute("config.row", &json!({"name":"Default","delete":true})).is_err()); + assert_eq!(s.doc, before, "last row remains while rules exist"); + assert!(s.execute("config.row", &json!({"name":"Too large","values":{"length":"30 mm"}})).is_err()); + assert_eq!(s.doc, before); + let impossible = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"15 mm","maximum":"20 mm"}}); + assert!(s.execute("config.rule", &json!({"rule":impossible})).is_err()); + assert_eq!(s.doc, before); + let preview = run(&mut s, "config.preview", json!({"row":"Default"})); + assert_eq!(preview["trace"][0]["name"], "Length limit"); + assert_eq!(preview["trace"][0]["matched"], true); + assert_eq!(s.doc, before); + run(&mut s, "config.rule", json!({"id":"limit","delete":true})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before); + let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(reopened.configs.rules, s.doc.configs.rules); + } + + #[test] + fn configuration_preview_and_failed_geometry_leave_design_unchanged() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name":"length", "expression":"10 mm"})); + run(&mut s, "solid.box", json!({"length":"length", "width":10, "height":10})); + run(&mut s, "config.configure", json!({"params":["length"]})); + run(&mut s, "config.row", json!({"name":"Large", "values":{"length":"20 mm"}})); + run(&mut s, "config.row", json!({"name":"Degenerate", "values":{"length":"0 mm"}})); + let before = s.doc.clone(); + let revision = s.revision; + assert_eq!(run(&mut s, "config.preview", json!({"row":"Large"}))["preview"], true); + assert_eq!(s.doc, before); + assert_eq!(s.revision, revision); + assert!(s.execute("config.preview", &json!({"row":"Degenerate"})).is_err()); + assert_eq!(s.doc, before); + assert!(s.execute("config.activate", &json!({"row":"Degenerate"})).is_err()); + assert_eq!(s.doc, before); + assert!((volume(&mut s) - 1000.0).abs() < 1e-6); + run(&mut s, "config.activate", json!({"row":"Large"})); + assert!((volume(&mut s) - 2000.0).abs() < 1e-6); + } + + #[test] + fn configured_feature_inputs_and_suppression_match_typed_cells() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10, "width":10, "height":10})); + let feature = s.doc.features[0].name.clone(); + let length = s.doc.features[0].param_names[0].clone(); + s.doc_mut().units = "in".into(); + run(&mut s, "config.configure", json!({"params":[length], "features":[feature]})); + assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm", "Default captures stored feature literals in their base unit"); + run(&mut s, "config.activate", json!({"row":"Default"})); + assert!((volume(&mut s) - 1000.0).abs() < 1e-6, "captured Default preserves geometry in an inch design"); + run(&mut s, "config.row", json!({"name":"Inches", "values":{length.clone():"2", format!("suppress:{feature}"):"NO"}})); + run(&mut s, "config.activate", json!({"row":"Inches"})); + assert_eq!(s.doc.config_current(&solvecraft_doc::config::Column::Param(length.clone())).as_deref(), Some("2 in")); + assert!(s.doc.configuration_matches()); + assert!((volume(&mut s) - 5080.0).abs() < 1e-6); + let feature_id = s.doc.features[0].id; + run( + &mut s, + "config.rule", + json!({"rule":{"id":"inch-condition","name":"Inch condition","condition":{"type":"parameter","parameter":length,"operator":"gt","expression":"5"},"effect":{"type":"drive_suppression","feature":feature_id,"value":true}}}), + ); + run( + &mut s, + "config.rule", + json!({"rule":{"id":"inch-range","name":"Inch range","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":length,"minimum":"0","maximum":"5"}}}), + ); + let preview = run(&mut s, "config.preview", json!({"row":"Inches"})); + assert_eq!(preview["trace"][0]["matched"], false, "bare comparison means inches for a feature length"); + assert_eq!(preview["trace"][1]["matched"], true); + } + + #[test] + fn configured_parameters_cannot_be_deleted_and_deleted_features_prune_columns() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name":"spare", "expression":"10 mm"})); + run(&mut s, "config.configure", json!({"params":["spare"]})); + assert!(s.doc_mut().remove_param("spare").is_err()); + run(&mut s, "config.column", json!({"param":"spare", "delete":true})); + s.doc_mut().remove_param("spare").unwrap(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); + let feature = &s.doc.features[0]; + let id = feature.id; + let length = feature.param_names[0].clone(); + let name = feature.name.clone(); + run(&mut s, "config.configure", json!({"params":[length], "features":[name]})); + s.doc_mut().delete_feature(id).unwrap(); + assert!(s.doc.configs.columns.is_empty()); + assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty())); + } + #[test] fn configurations_switch_sizes_and_suppressions() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/delete.rs b/crates/engine/src/cmd/delete.rs index ec2498ca..8b35169c 100644 --- a/crates/engine/src/cmd/delete.rs +++ b/crates/engine/src/cmd/delete.rs @@ -65,8 +65,12 @@ fn apply(s: &mut Session, p: &Value) -> Result { let mut features: Vec = Vec::new(); let mut bodies: Vec = Vec::new(); let mut faces: Vec<(String, Vec)> = Vec::new(); + let mut selected_occurrences = Vec::new(); for x in &items { match x { + Sel::Occurrence { path } => { + selected_occurrences.push(s.occurrence_placement(path)?.occurrence.to_string()); + } Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()), Sel::Feature { id } => { if s.doc.feature(*id).is_none() { @@ -118,7 +122,10 @@ fn apply(s: &mut Session, p: &Value) -> Result { } // An occurrence goes alone while its component has others; the last one takes the component. let mut components: Vec = Vec::new(); - for k in keys(p, "occurrences") { + selected_occurrences.extend(keys(p, "occurrences")); + selected_occurrences.sort(); + selected_occurrences.dedup(); + for k in selected_occurrences { let Some(o) = s.doc.occurrences.iter().find(|o| o.id.to_string() == k || o.name == k).cloned() else { return Err(bad(cmd, format!("no occurrence `{k}`"))); }; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ddf62105..d6a975a4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -238,6 +238,9 @@ fn select_set(s: &mut Session, p: &Value) -> Result { let mut sel: Vec = Vec::new(); for it in items { let one: Sel = serde_json::from_value(it).map_err(|e| bad("select.set", e.to_string()))?; + if let Sel::Occurrence { path } = &one { + s.occurrence_bodies(path, true).map_err(|e| bad("select.set", e.to_string()))?; + } sel.push(one); } if !bool_(p, "add").unwrap_or(false) { diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 342a29e7..dad2533e 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -62,13 +62,14 @@ fn measure(s: &mut Session, p: &Value) -> Result { } let want = string_list(p, "bodies"); let st = s.world_state(); - let mut bodies: Vec = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect(); + let selected: Vec<_> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).collect(); + let mut bodies: Vec = selected.iter().map(|b| measure_json(b)).collect(); // Mass from each body's material. - for b in &mut bodies { - let name = b["name"].as_str().unwrap_or_default().to_string(); + for (b, body) in bodies.iter_mut().zip(selected) { if let Some(v) = b["volume_mm3"].as_f64() { - let rho = s.doc.density(&name); - b["material"] = json!(s.doc.materials.get(&name).cloned().unwrap_or_else(|| "Default".into())); + let source = body.definition_name(); + let rho = s.doc.density(source); + b["material"] = json!(s.doc.materials.get(source).cloned().unwrap_or_else(|| "Default".into())); b["density_g_cm3"] = json!(rho); b["mass_g"] = json!(v * rho / 1000.0); } @@ -214,7 +215,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result { .map(|b| { let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }; if let Some(o) = v.as_object_mut() { - o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name))); + o.insert("visible".into(), json!(s.body_visible(&b.name))); } v }) @@ -495,7 +496,7 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result { let b = st.body(name).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?; out["body"] = json!({ "name": name, - "appearance": s.doc.body_look(&b.name, b.feature).map(|l| look_json(&l)), + "appearance": s.doc.model_body_look(b).map(|l| look_json(&l)), "color": s.doc.body_color(b), "faces": s.doc.face_colors(b).iter().map(|(i, l)| json!({"face": i, "appearance": look_json(l)})).collect::>(), }); diff --git a/crates/engine/src/cmd/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs new file mode 100644 index 00000000..7a6f0905 --- /dev/null +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -0,0 +1,421 @@ +// Exact occurrence workflows through the same commands used by the Browser. +use crate::Session; +use serde_json::{Value, json}; + +fn run(s: &mut Session, command: &str, p: Value) -> Value { + s.execute(command, &p).unwrap() +} +fn created(s: &mut Session, name: &str) -> (u64, u64) { + let c = run(s, "component.create", json!({"name": name})); + (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()) +} +fn near(value: &Value, expected: f64) { + assert!((value.as_f64().unwrap() - expected).abs() < 1e-6, "{value} != {expected}"); +} + +#[test] +fn instance_copy_uses_selected_source_transform() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Block"); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [30,0,0]})); + let third = run(&mut s, "occurrence.copy", json!({"occurrence": second, "translate": [5,0,0]}))["occurrence"].as_u64().unwrap(); + let placement = s.doc.occurrences.iter().find(|o| o.id == third).unwrap(); + assert!((placement.transform[3][0] - 55.0).abs() < 1e-6); + assert_eq!(s.doc.occurrences.iter().find(|o| o.id == first).unwrap().transform[3][0], 0.0); +} + +#[test] +fn instance_world_move_under_rotated_parent_uses_selected_path() { + let mut s = Session::default(); + let (_, parent) = created(&mut s, "Parent"); + run(&mut s, "occurrence.move", json!({"occurrence": parent, "axis": [0,0,1], "angle": "90 deg"})); + let (_, child) = created(&mut s, "Child"); + run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1})); + run(&mut s, "occurrence.move", json!({"occurrence": child, "path": [parent, child], "space": "world", "translate": [10,0,0]})); + let result = run(&mut s, "occurrence.properties", json!({"occurrence": child, "path": [parent,child]})); + near(&result["total"]["center_of_mass"][0], 9.5); + near(&result["total"]["center_of_mass"][1], 0.5); +} + +#[test] +fn instance_authoring_at_second_placement_keeps_definition_shared() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Block"); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component": component, "occurrence": second, "path": [second]})); + run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [20,0,0], "operation": "new"})); + for (occurrence, x) in [(first, 1.0), (second, 21.0)] { + let result = run(&mut s, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]})); + near(&result["total"]["center_of_mass"][0], x); + } +} + +#[test] +fn instance_selection_survives_rename_move_capture_and_serde() { + let mut s = Session::default(); + let (component, _) = created(&mut s, "Block"); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + let selected = json!({"type": "occurrence", "path": [second]}); + run(&mut s, "select.set", json!({"items": [selected]})); + run(&mut s, "component.rename", json!({"component": component, "name": "Renamed"})); + run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [5,0,0], "capture": false})); + run(&mut s, "component.capture_position", json!({})); + assert_eq!(serde_json::to_value(&s.selection).unwrap(), json!([selected])); + let roundtrip: Vec = serde_json::from_value(json!([selected])).unwrap(); + assert_eq!(roundtrip, s.selection); +} + +#[test] +fn instance_selection_delete_removes_only_selected_instance() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Block"); + run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2})); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"items": [{"type": "occurrence", "path": [second]}]})); + let items = s.selection.clone(); + run(&mut s, "selection.delete", json!({"items": items})); + assert!(s.doc.occurrences.iter().any(|o| o.id == first)); + assert!(!s.doc.occurrences.iter().any(|o| o.id == second)); + assert_eq!(s.world_state().bodies.len(), 1); + assert!(s.doc.components.iter().any(|c| c.id == component)); +} + +#[test] +fn world_move_includes_pending_parent_and_leaf_and_shared_scope() { + let mut s = Session::default(); + let (parent_component, parent) = created(&mut s, "Parent"); + let (_, child) = created(&mut s, "Child"); + run(&mut s, "occurrence.move", json!({"occurrence":parent,"axis":[0,0,1],"angle":"90 deg","translate":[3,0,0],"capture":false})); + run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[2,0,0],"capture":false})); + let before = s.occurrence_placement(&[parent, child]).unwrap().world; + run(&mut s, "occurrence.move", json!({"occurrence":child,"path":[parent,child],"space":"world","translate":[10,0,0],"capture":false})); + let after = s.occurrence_placement(&[parent, child]).unwrap().world; + assert!((after[3][0] - before[3][0] - 10.0).abs() < 1e-8); + assert!((after[3][1] - before[3][1]).abs() < 1e-8); + run( + &mut s, + "occurrence.move", + json!({"occurrence":child,"path":[parent,child],"space":"world","axis":[0,0,1],"angle":"90 deg","origin":[0,0,0],"capture":false}), + ); + let rotated = s.occurrence_placement(&[parent, child]).unwrap().world; + assert!((rotated[3][0] + after[3][1]).abs() < 1e-8); + assert!((rotated[3][1] - after[3][0]).abs() < 1e-8); + run(&mut s, "component.activate", json!({"component":parent_component})); + let second_parent = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[30,0,0]}))["occurrence"].as_u64().unwrap(); + assert_eq!(s.occurrence_scope_count(&[second_parent, child]).unwrap(), 2); + assert!(s.execute("occurrence.move", &json!({"occurrence":child,"space":"world","translate":[1,0,0]})).is_err()); +} + +#[test] +fn invalid_paths_are_atomic_for_session_state() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Part"); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); + run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]})); + let pending = s.pending_moves.clone(); + let active = s.active_occurrence_path.clone(); + let vis = s.visibility.clone(); + let doc = s.doc.clone(); + for (cmd, p) in [ + ("occurrence.move", json!({"occurrence":second,"path":[first],"space":"world","translate":[10,0,0]})), + ("occurrence.move", json!({"occurrence":second,"space":17,"translate":[10,0,0]})), + ("component.activate", json!({"component":component,"occurrence":second,"path":[first,second]})), + ("browser.visibility", json!({"visible":true,"paths":[[second],[9999]]})), + ("select.set", json!({"items":[{"type":"occurrence","path":[first,second]}]})), + ("occurrence.copy", json!({"occurrence":second,"path":[first]})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd}"); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.active_occurrence_path, active); + assert_eq!(s.visibility, vis); + assert_eq!(s.doc, doc); + } +} + +#[test] +fn exact_visibility_isolate_rename_and_async_accept() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + let first_body = s.occurrence_bodies(&[first], true).unwrap()[0].clone(); + let second_body = s.occurrence_bodies(&[second], true).unwrap()[0].clone(); + run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]})); + assert!(s.body_visible(&first_body)); + assert!(!s.body_visible(&second_body)); + let mut scratch = s.scratch(); + assert_eq!(scratch.visibility, s.visibility); + run(&mut scratch, "component.rename", json!({"component":component,"name":"Changed"})); + s.accept_computed(scratch); + assert!(!s.occurrence_visible(&[second])); + run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[second]],"isolate":true})); + assert!(!s.body_visible(&first_body)); + assert!(s.body_visible(&second_body)); + run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"})); + assert!(s.body_visible(&first_body)); + assert!(s.body_visible(&second_body)); +} + +#[test] +fn synthesized_name_collision_keeps_source_and_instance_separate() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let root = s.model.state().bodies[0].name.clone(); + let (_, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + run(&mut s, "body.rename", json!({"body":root,"name":candidate})); + let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + assert_ne!(instance, candidate); + let frames = s.doc.body_frames(&s.model.state()); + let world = s.world_state(); + assert!(frames.iter().all(|(name, _, _, _)| world.body(name).is_some())); + assert_eq!(world.bodies.len(), world.bodies.iter().map(|b| &b.name).collect::>().len()); + run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]})); + assert!(s.body_visible(&candidate)); + assert!(!s.body_visible(&instance)); + let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone(); + run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"})); + assert_eq!(s.doc.materials.get(&source).map(String::as_str), Some("Brass")); + assert!(!s.doc.materials.contains_key(&candidate)); +} + +#[test] +fn duplicate_nested_labels_use_full_path_and_reopen_undo_preserve_placements() { + let mut s = Session::default(); + let (parent_component, parent) = created(&mut s, "Parent"); + let (_, child) = created(&mut s, "Child"); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let parent2 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap(); + let parent3 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap(); + for o in s.doc_mut().occurrences.iter_mut().filter(|o| o.id == parent2 || o.id == parent3) { + o.name = "Same".into(); + } + s.refresh(); + let a = s.occurrence_bodies(&[parent2, child], false).unwrap()[0].clone(); + let b = s.occurrence_bodies(&[parent3, child], false).unwrap()[0].clone(); + assert_ne!(a, b); + let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap()); + assert_eq!(reopened.occurrence_bodies(&[parent2, child], false).unwrap()[0], a); + assert_eq!(reopened.occurrence_bodies(&[parent3, child], false).unwrap()[0], b); + run(&mut reopened, "selection.delete", json!({"occurrences":[parent3]})); + assert!(reopened.occurrence_placement(&[parent3, child]).is_err()); + run(&mut reopened, "edit.undo", json!({})); + assert!(reopened.occurrence_placement(&[parent3, child]).is_ok()); + assert!(reopened.occurrence_placement(&[parent, child]).is_ok()); +} + +#[test] +fn compact_duplicated_assembly_exact_resolution_and_expansion_limit() { + let mut s = Session::default(); + let mut parent = 0; + let mut path = Vec::new(); + for i in 0..18 { + let component = s.doc_mut().add_component(Some(&format!("Level{i}")), parent).unwrap(); + let occurrence = s.doc.occurrence_of(component).unwrap().id; + path.push(occurrence); + s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap(); + parent = component; + } + assert!(s.occurrence_placement(&path).is_ok()); + let top = [path[0]]; + assert!(s.occurrence_bodies(&top, true).is_err()); + assert!(s.execute("select.set", &json!({"items":[{"type":"occurrence","path":top}]})).is_err()); + assert!(s.selection.is_empty()); +} + +#[test] +fn identity_instances_map_world_keys_to_shared_sources() { + let mut s = Session::default(); + let (_, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap(); + let placement = s.occurrence_placement(&[second]).unwrap(); + let body = &placement.bodies[0]; + assert_ne!(body.source, body.world); + run(&mut s, "material.assign", json!({"bodies":[body.world],"material":"Brass"})); + assert_eq!(s.doc.materials.get(&body.source).map(String::as_str), Some("Brass")); +} + +#[test] +fn dialog_baseline_move_preserves_other_pending_positions_without_drift() { + let mut s = Session::default(); + let (_, first) = created(&mut s, "Part"); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[5,0,0],"capture":false})); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[3,0,0],"capture":false})); + let baseline = s.occurrence_placement(&[second]).unwrap().local; + for offset in [10.0, 15.0, 0.0] { + run( + &mut s, + "occurrence.move", + json!({"occurrence":second,"path":[second],"space":"world","base_transform":baseline,"translate":[offset,0,0],"capture":false}), + ); + assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0); + assert_eq!(s.pending_moves.get(&second).unwrap()[3][0], 23.0 + offset); + } + let pending = s.pending_moves.clone(); + assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,1]]})).is_err()); + assert_eq!(s.pending_moves, pending); + for base in [ + [[2.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], + [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], + ] { + assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":base})).is_err()); + assert_eq!(s.pending_moves, pending); + } + assert!(s.execute("component.revert_position", &json!({"occurrences":[second,9999]})).is_err()); + assert_eq!(s.pending_moves, pending); + run(&mut s, "component.revert_position", json!({"occurrences":[second]})); + assert!(!s.pending_moves.contains_key(&second)); + assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0); +} + +#[test] +fn hostile_moves_copies_and_reused_ids_do_not_leak_state() { + let mut s = Session::default(); + let (_, first) = created(&mut s, "Part"); + run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[10,0,0],"capture":false})); + let pending = s.pending_moves.clone(); + let doc = s.doc.clone(); + for (cmd, p) in [ + ("occurrence.move", json!({"occurrence":first,"origin":[1e308,0,0],"angle":"180 deg"})), + ("occurrence.move", json!({"occurrence":first,"translate":[1e9,0,0],"capture":false})), + ("occurrence.move", json!({"occurrence":first,"translate":[1,2]})), + ("occurrence.move", json!({"occurrence":first,"angle":false})), + ("occurrence.move", json!({"occurrence":first,"axis":[1e308,0,0],"angle":"90 deg"})), + ("occurrence.move", json!({"occurrence":first,"axis":[0,0,0],"angle":"90 deg"})), + ("occurrence.copy", json!({"occurrence":first,"translate":[1e9,0,0]})), + ("occurrence.copy", json!({"occurrence":first,"translate":"oops"})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd}"); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.doc, doc); + } + run(&mut s, "edit.undo", json!({})); + assert!(s.pending_moves.is_empty()); + let (_, other) = created(&mut s, "Other"); + assert_eq!(first, other); + assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0); + run(&mut s, "occurrence.move", json!({"occurrence":other,"translate":[20,0,0],"capture":false})); + run(&mut s, "component.capture_position", json!({})); + assert!(s.pending_moves.is_empty()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0); +} + +#[test] +fn isolate_nested_child_hides_ancestor_material_and_sibling() { + let mut s = Session::default(); + let (_, parent) = created(&mut s, "Parent"); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); + let (child_component, child) = created(&mut s, "Child"); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let parent_component = s.doc.occurrences.iter().find(|o| o.id == child).unwrap().parent; + let sibling = s + .doc_mut() + .add_occurrence( + child_component, + parent_component, + solvecraft_doc::rigid(crate::geom::Vec3::new(20.0, 0.0, 0.0), crate::geom::Vec3::ZERO, crate::geom::Vec3::Z, 0.0), + ) + .unwrap(); + s.refresh(); + run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[parent,child]],"isolate":true})); + for (path, expected) in [(vec![parent], false), (vec![parent, child], true), (vec![parent, sibling], false)] { + let body = s.occurrence_bodies(&path, false).unwrap()[0].clone(); + assert_eq!(s.body_visible(&body), expected); + } +} + +#[test] +fn context_new_child_uses_inactive_parent_placement() { + let mut s = Session::default(); + let (parent_component, parent) = created(&mut s, "Parent"); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":0})); + let child = + run(&mut s, "component.create", json!({"parent":parent_component,"parent_path":[second],"name":"Child"}))["occurrence"].as_u64().unwrap(); + assert_eq!(s.active_occurrence_path, Some(vec![second, child])); + assert_eq!(s.active_component_frame()[3][0], 20.0); + let doc = s.doc.clone(); + let active = s.active_occurrence_path.clone(); + assert!(s.execute("component.create", &json!({"parent":parent_component,"parent_path":[999],"name":"Invalid"})).is_err()); + assert_eq!(s.doc, doc); + assert_eq!(s.active_occurrence_path, active); +} + +#[test] +fn paste_new_uses_selected_pending_placement() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[30,0,0],"capture":false})); + let copied = + run(&mut s, "component.paste_new", json!({"component":component,"occurrence":second,"path":[second],"translate":[5,0,0]}))["component"] + .as_u64() + .unwrap(); + assert_eq!(s.doc.occurrence_of(copied).unwrap().transform[3][0], 55.0); + let result = run(&mut s, "component.properties", json!({"component":copied})); + near(&result["total"]["center_of_mass"][0], 55.5); + let result = run(&mut s, "occurrence.properties", json!({"occurrence":first,"path":[first]})); + near(&result["total"]["center_of_mass"][0], 0.5); +} + +#[test] +fn instance_styles_follow_source_identity_after_collision_rename_and_pending_move() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1})); + let root = s.model.state().bodies[0].name.clone(); + let (component, first) = created(&mut s, "Part"); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + run(&mut s, "body.rename", json!({"body":root,"name":candidate})); + let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone(); + run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"})); + let world = s.world_state(); + assert_eq!(s.doc.body_color(world.body(&instance).unwrap()), s.doc.body_color(world.body(&source).unwrap())); + run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"color":"#0000ff","opacity":0.7})); + assert_eq!(s.doc.body_color(s.world_state().body(&instance).unwrap()), Some([0.0, 0.0, 1.0])); + run(&mut s, "appearance.assign", json!({"bodies":[instance],"color":"#ff0000","opacity":0.5})); + run(&mut s, "appearance.assign", json!({"faces":[{"body":instance,"point":[21,1,2]}],"color":"#00ff00","opacity":0.4})); + // Exercise the point-based face fallback as well as persistent face-name matching. + for face in &mut s.doc_mut().appearances.faces { + face.name = None; + } + run(&mut s, "body.rename", json!({"body":source,"name":"Shared Renamed"})); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); + let world = s.world_state(); + for id in [first, second] { + let bodyname = s.occurrence_bodies(&[id], false).unwrap()[0].clone(); + let body = world.body(&bodyname).unwrap(); + assert_eq!(body.definition_name(), "Shared Renamed"); + assert_eq!(body.component, component); + let look = s.doc.model_body_look(body).unwrap(); + assert_eq!(look.color, [255, 0, 0]); + assert_eq!(look.opacity, 0.5); + assert!(s.doc.face_colors(body).iter().any(|(_, l)| l.color == [0, 255, 0] && l.opacity == 0.4)); + let measured = run(&mut s, "inspect.measure", json!({"bodies":[bodyname]})); + assert_eq!(measured["bodies"][0]["material"], "Brass"); + assert!((measured["bodies"][0]["mass_g"].as_f64().unwrap() - 0.068).abs() < 1e-8); + } + let definition = s.model.state(); + assert_eq!(solvecraft_kernel::measure(&definition.body("Shared Renamed").unwrap().body).unwrap().bbox.min, crate::geom::Vec3::ZERO); + let painted = s.doc.painted(&world); + let secondname = s.occurrence_bodies(&[second], false).unwrap()[0].clone(); + let body = painted.body(&secondname).unwrap(); + assert_eq!(body.body.color(), Some([1.0, 0.0, 0.0])); + assert!(body.body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0])); + let bytes = solvecraft_io::export(&painted, &[secondname], solvecraft_io::Format::Step, "InstanceStyles").unwrap(); + let imported = solvecraft_kernel::step_import(&String::from_utf8(bytes).unwrap()).unwrap(); + assert_eq!(imported.bodies.len(), 1); + assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0])); + assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0])); +} diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 39976b67..d04d2fe0 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -274,11 +274,17 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result { } fn flat_json(sh: &SheetBody) -> Result { - let (w, h) = sh.flat_size()?; - let loops = sh.outline()?; + let regions = sh.flat_regions()?; + let loops: Vec> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect(); + let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); + for p in loops.iter().flatten() { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + let (w, h) = (hi.x - lo.x, hi.y - lo.y); let pt = |v: &Vec2| json!([v.x, v.y]); let outline: Vec = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect(); - let holes: Vec = sh.holes.iter().map(|h| Value::Array(h.outer.polyline(1e-3).iter().map(pt).collect())).collect(); + let holes: Vec = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect(); let bends: Vec = sh .bends() .iter() @@ -331,16 +337,23 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { w(11, &f(b.x)); w(21, &f(b.y)); }; - for l in sh.outline()? { - let m = l.len(); - for i in 0..m { - if let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % m)) { - line(&mut w, "OUTLINE", *a, *b); + let regions = sh.flat_regions()?; + for h in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) { + // A circle is often represented by two semicircles. Keep the whole analytic + // boundary as one CIRCLE rather than approximating outer boundaries with chords. + if let Some(solvecraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() { + let total: f64 = h.segs.iter().filter_map(|s| if let solvecraft_geom::Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum(); + let circular = h.segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum())); + if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 { + w(0, "CIRCLE"); + w(8, "OUTLINE"); + w(10, &f(center.x)); + w(20, &f(center.y)); + w(40, &f(*radius)); + continue; } } - } - for h in &sh.holes { - for seg in &h.outer.segs { + for seg in &h.segs { match *seg { solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => { if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 { diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 3bfe4c6b..524eff6d 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -421,3 +421,328 @@ fn flat_dxf_preserves_exact_cutout_entities() { assert_eq!(read.len(), 6); assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Arc { c, a, b } if *c == Vec2::new(20.0, 10.0) && a.dist(Vec2::new(30.0, 10.0)) < 1e-9 && b.dist(Vec2::new(10.0, 10.0)) < 1e-9))); } + +// A profile chosen by a point outside its inner loop must keep that inner loop through +// sheet construction, flattening and manufacturing export. +#[test] +fn inherited_base_profile_holes_survive_every_sheet_state() { + for flip in [false, true] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 20], "p1": [60, 40]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip})); + assert!((volume(&mut s) - 14000.0).abs() < 1e-6); + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"})); + run(&mut reopened, "sketch.finish", json!({})); + let pick = if flip { [10, -10] } else { [10, 10] }; + run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": pick}], "distance": 2.5})); + assert!((volume(&mut reopened) - 14000.0).abs() < 1e-5); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 14000.0).abs() < 1e-6); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - 14000.0).abs() < 1e-6); + } +} + +#[test] +fn edge_notch_uses_the_same_material_boundary_in_solid_flat_and_dxf() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Notch"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, 20], "p1": [10, 40]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Notch", "through_all": true, "operation": "cut", "direction": "symmetric"})); + assert!((volume(&mut s) - 14500.0).abs() < 1e-6); + let f = flat(&mut s); + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1)); + assert_eq!(f["outline"][0].as_array().map(Vec::len), Some(8)); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"})); + run(&mut reopened, "sketch.finish", json!({})); + run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "distance": 2.5})); + assert!((volume(&mut reopened) - 14500.0).abs() < 1e-5); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 14500.0).abs() < 1e-6); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - 14500.0).abs() < 1e-6); +} + +#[test] +fn inherited_round_hole_survives_flange_and_unfold_in_flipped_frames() { + for flip in [false, true] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); + run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip})); + run(&mut s, "sheet.flange", json!({"edges": [[50, 0, if flip { -2.5 } else { 2.5 }]], "height": 20})); + let folded = volume(&mut s); + assert!((folded - (18972.62196081845 - PI * 25.0 * 2.5)).abs() < 0.2, "{folded}"); + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1)); + let sh = s.model.state().sheets[0].clone(); + let regions = sh.flat_regions().unwrap(); + assert_eq!(regions[0].holes[0].segs.len(), 2); + assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); + let expected = regions.iter().map(solvecraft_geom::Region2::area).sum::() * 2.5; + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.1); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - folded).abs() < 0.1); + } +} + +#[test] +fn crossing_only_disconnecting_cut_preserves_all_material_states() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Split"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45, -5], "p1": [55, 65]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Split", "through_all": true, "operation": "cut", "direction": "symmetric"})); + assert!((volume(&mut s) - 13500.0).abs() < 1e-6); + assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "SplitBlank"})); + run(&mut reopened, "sketch.finish", json!({})); + run(&mut reopened, "solid.extrude", json!({"sketch": "SplitBlank", "distance": 2.5})); + assert!((volume(&mut reopened) - 13500.0).abs() < 1e-6); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 13500.0).abs() < 1e-6); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - 13500.0).abs() < 1e-6); +} + +#[test] +fn splitting_an_unfolded_flanged_sheet_preserves_both_bent_pieces() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sheet.flange", json!({"edges": [[50,0,2.5]], "height": 20})); + let folded = volume(&mut s); + run(&mut s, "sheet.unfold", json!({})); + let unfolded = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BlankSplit"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45,-100], "p1": [55,100]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "BlankSplit", "through_all": true, "operation": "cut", "direction": "symmetric"})); + assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1); + assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - folded * 0.9).abs() < 0.2); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1); +} + +#[test] +fn repeated_and_nested_sheet_cuts_are_redundant() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + for (i, radius) in [3.0, 5.0, 5.0].into_iter().enumerate() { + let name = format!("Hole{i}"); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": name})); + run(&mut s, "sketch.circle.center", json!({"center": [20, 30], "radius": radius})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"})); + } + assert!((volume(&mut s) - (6000.0 - PI * 25.0) * 2.5).abs() < 0.1); + assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1)); +} + +#[test] +fn overlapping_round_cuts_keep_exact_union_in_export_and_roundtrip() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + for (i, x) in [20.0, 22.0].into_iter().enumerate() { + let name = format!("Overlap{i}"); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": name})); + run(&mut s, "sketch.circle.center", json!({"center": [x, 30], "radius": 5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"})); + } + let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt(); + let expected = (6000.0 - (50.0 * PI - intersection)) * 2.5; + assert!((volume(&mut s) - expected).abs() < 0.1); + assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert!(dxf.contains("0\nARC\n")); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"})); + run(&mut reopened, "sketch.finish", json!({})); + run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": [10, 10]}], "distance": 2.5})); + assert!((volume(&mut reopened) - expected).abs() < 0.1); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.1); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.1); +} + +#[test] +fn cut_across_a_concave_notch_uses_crossings_not_just_contained_corners() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "U"})); + run(&mut s, "sketch.line", json!({"points": [[0,0],[100,0],[100,60],[70,60],[70,20],[30,20],[30,60],[0,60],[0,0]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "U"})); + assert!((volume(&mut s) - 11000.0).abs() < 1e-6); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "ArmNotches"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,30], "p1": [80,40]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "ArmNotches", "through_all": true, "operation": "cut", "direction": "symmetric"})); + assert!((volume(&mut s) - 10500.0).abs() < 1e-6); + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0)); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 10500.0).abs() < 1e-6); +} + +#[test] +fn tiny_cut_inside_exact_circle_survives_unfold_refold_and_dxf() { + for initially_flat in [false, true] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Disc"})); + run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 10})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Disc"})); + let circle = s.model.state().sheets[0].base.outer.clone(); + let displayed = circle.polyline(1e-3); + let at = (displayed[0] + displayed[1]).normalized().unwrap() * 9.9998; + assert!(!circle.contains(at)); + if initially_flat { + run(&mut s, "sheet.unfold", json!({})); + } + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TinyCut"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [at.x-0.000025,at.y-0.000025], "p1": [at.x+0.000025,at.y+0.000025]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "TinyCut", "through_all": true, "operation": "cut", "direction": "symmetric"})); + for stage in ["sheet.unfold", "sheet.refold"] { + let regions = s.model.state().sheets[0].flat_regions().unwrap(); + assert_eq!(regions[0].holes.len(), 1); + assert!((regions[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13); + assert_eq!(s.model.state().bodies.len(), 1); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 5); + if stage == "sheet.unfold" && initially_flat { + run(&mut s, "sheet.refold", json!({})); + } + run(&mut s, stage, json!({})); + } + assert_eq!(s.model.state().sheets[0].flat_regions().unwrap()[0].holes.len(), 1); + } +} + +#[test] +fn unrelated_box_cut_preserves_logical_ring_sheet_compound() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let sheet_name = s.model.state().sheets[0].body.clone(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"})); + for radius in [10, 5] { + run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": radius})); + } + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}), + ); + let material = s.model.state().sheets[0].flat_regions().unwrap(); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [200,0,0], "body_name": "SeparateBox"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BoxCut"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [205,5], "p1": [210,10]})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "BoxCut", "participants": ["SeparateBox"], "through_all": true, "operation": "cut", "direction": "symmetric"}), + ); + assert_eq!(s.model.state().bodies.len(), 2); + assert_eq!(s.model.state().sheets[0].flat_regions().unwrap(), material); + for stage in ["sheet.unfold", "sheet.refold"] { + run(&mut s, stage, json!({"body": sheet_name})); + assert_eq!(s.model.state().bodies.len(), 2); + let state = s.model.state(); + let body = &state.bodies.iter().find(|b| b.name == sheet_name).unwrap().body; + assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25))); + assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.25))); + } +} + +#[test] +fn specialized_sheet_cuts_respect_component_and_explicit_participants() { + for explicit in [false, true] { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + let name = s.model.state().sheets[0].body.clone(); + run(&mut s, "component.create", json!({"name": "Other"})); + run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10, "body_name": "OtherBox"})); + if explicit { + run(&mut s, "component.activate", json!({"component": "root"})); + } + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,20], "p1": [30,30]})); + run(&mut s, "sketch.finish", json!({})); + let mut params = json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}); + if explicit { + params["participants"] = json!(["OtherBox"]); + } + run(&mut s, "solid.extrude", params); + let m = run(&mut s, "inspect.measure", json!({"bodies": [name]})); + assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 15000.0).abs() < 1e-6); + assert!(s.model.state().sheets[0].holes.is_empty()); + let m = run(&mut s, "inspect.measure", json!({"bodies": ["OtherBox"]})); + assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 59000.0).abs() < 1e-6); + } +} + +#[test] +fn ring_cut_preserves_retained_island_through_unfold_and_refold() { + fn material_probes(s: &Session) { + let body = &s.model.state().bodies[0].body; + assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25))); + assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.25))); + assert!(body.contains(solvecraft_geom::Vec3::new(65.0, 30.0, 1.25))); + } + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"})); + run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": 10})); + run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": 5})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}), + ); + let expected = (6000.0 - PI * 75.0) * 2.5; + assert!((volume(&mut s) - expected).abs() < 0.2); + material_probes(&s); + assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); + let regions = s.model.state().sheets[0].flat_regions().unwrap(); + assert!(regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(50.0, 30.0)))); + assert!(!regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(57.0, 30.0)))); + assert!(regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(65.0, 30.0)))); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + let mut reopened = Session::default(); + run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "RingBlank"})); + run(&mut reopened, "sketch.finish", json!({})); + run(&mut reopened, "solid.extrude", json!({"sketch": "RingBlank", "profiles": [{"point": [10,10]}, {"point": [50,30]}], "distance": 2.5})); + assert!((volume(&mut reopened) - expected).abs() < 0.2); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.2); + material_probes(&s); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - expected).abs() < 0.2); + material_probes(&s); +} diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 3a390268..35fe4a68 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -9,6 +9,10 @@ use super::{CommandSpec, in_sketch}; use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3}; use crate::{EngineError, Result, Session}; +#[path = "sketch_dimensioned.rs"] +mod dimensioned; +use dimensioned::draw as draw_dimensioned; + /// The `connect` parameter every drawing command takes (see `connect_points`). macro_rules! connect_doc { () => { @@ -29,6 +33,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("L") .enabled(in_sketch) .params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())), + CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned) + .enabled(in_sketch) + .params("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step"), CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point) .at("SKETCH", "CREATE") .icon("rect") @@ -355,7 +362,8 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { // A planar face of a body at this point, as the active component sees the model // (another component's face where it is placed). let model = s.model.state(); - let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component); + let placed = s.placed_document(); + let st = solvecraft_doc::state_in_frame_at(&placed, &model, s.active_component, s.active_component_frame()); let mut found = None; let mut face_name = None; for b in &st.bodies { @@ -792,6 +800,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result { } fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result { + let mut support_circle = String::new(); let (out, info) = draw(s, p, cmd, |sk, _| { let corners: Vec = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect(); let (l, pts) = polygon_lines(sk, &corners)?; @@ -804,6 +813,7 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s // Construction circle tying the polygon to its centre. let rc = if inscribed { r_vertex } else { r_vertex * (std::f64::consts::PI / n as f64).cos() }; let circ = sk.add_circle(center, rc, None, None)?; + support_circle = sk.curves.get(circ).map(|c| c.id.clone()).unwrap_or_default(); if let Some(cu) = sk.curves.get_mut(circ) { cu.construction = true; } @@ -818,13 +828,15 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s } Ok((ids_of(sk, &l), cons)) })?; - Ok(result(out, info)) + let mut value = result(out, info); + value["support_circle"] = json!(support_circle); + Ok(value) } fn sides(p: &Value, cmd: &str) -> Result { let n = num(p, "sides").unwrap_or(6.0); - if !(3.0..=200.0).contains(&n) { - return Err(bad(cmd, "sides must be 3…200")); + if !(3.0..=200.0).contains(&n) || n.fract() != 0.0 { + return Err(bad(cmd, "sides must be an integer from 3 to 200")); } Ok(n as usize) } @@ -1333,9 +1345,18 @@ fn dimension(s: &mut Session, p: &Value) -> Result { // The other reading of an angle between two lines (see below). let mut alt: Option<(ConstraintKind, f64)> = None; let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) { + ((None, Some(c)), None) if ty == "sweep" => { + let angle = arc_len(sk, c).zip(sk.radius(c)).map(|(len, r)| len / r).ok_or_else(|| bad(cmd, "sweep needs an arc"))?; + let angle = if sk.curves.get(c).is_some_and(|c| c.reversed) { -angle } else { angle }; + (ConstraintKind::ArcSweep { c, value: angle }, angle) + } ((None, Some(c)), None) if is_line(c) => { let (a, b) = line_pts(sk, c).unwrap_or_default(); match ty.as_str() { + "angle" => { + let angle = (b - a).angle(); + (ConstraintKind::LineAngle { l: c, value: angle }, angle) + } "horizontal" => { (ConstraintKind::DistanceX { p: line_ends(sk, c).0, q: line_ends(sk, c).1, value: (b.x - a.x).abs() }, (b.x - a.x).abs()) } @@ -1384,6 +1405,15 @@ fn dimension(s: &mut Session, p: &Value) -> Result { (ConstraintKind::PointLineDistance { p: q, l, value: d }, d) } } + ((None, Some(a)), Some((None, Some(b)))) if ty == "overall_length" => { + let current = sk + .center(a) + .zip(sk.center(b)) + .zip(sk.radius(a).zip(sk.radius(b))) + .map(|((ca, cb), (ra, rb))| ca.dist(cb) + ra + rb) + .ok_or_else(|| bad(cmd, "overall slot length needs two arc caps"))?; + (ConstraintKind::SlotOverallLength { a, b, value: current }, current) + } ((None, Some(a)), Some((None, Some(b)))) if is_line(a) && is_line(b) => { let (a0, a1) = line_pts(sk, a).unwrap_or_default(); let (b0, b1) = line_pts(sk, b).unwrap_or_default(); diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs index 16d6ea57..d95ec5a0 100644 --- a/crates/engine/src/cmd/sketch_constraints.rs +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -434,8 +434,9 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option { } Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5), PointLineDistance { p, .. } | LinearDiameter { p, .. } => pt(p), - Length { l, .. } => mid(l), - Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } => mid(c), + Length { l, .. } | LineAngle { l, .. } => mid(l), + Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } | ArcSweep { c, .. } => mid(c), + SlotOverallLength { a, b, .. } => Some((sk.center(a)? + sk.center(b)?) * 0.5), } } diff --git a/crates/engine/src/cmd/sketch_dimensioned.rs b/crates/engine/src/cmd/sketch_dimensioned.rs new file mode 100644 index 00000000..f9ed36c9 --- /dev/null +++ b/crates/engine/src/cmd/sketch_dimensioned.rs @@ -0,0 +1,114 @@ +//! Shape creation and its typed driving dimensions form one command and one undo step. +use serde_json::{Value, json}; +use solvecraft_doc::expr::Kind; + +use crate::{ + Result, Session, + params::{bad, expr, str_}, +}; + +pub(super) fn draw(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.draw.dimensioned"; + let create = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name a supported sketch creator"))?; + let roles: &[&str] = match create { + "sketch.line" => &["length", "angle"], + "sketch.rectangle.two_point" | "sketch.rectangle.center" | "sketch.rectangle.three_point" => &["width", "height"], + "sketch.circle.center" | "sketch.circle.two_point" | "sketch.circle.three_point" => &["radius", "diameter"], + "sketch.arc.center_point" => &["radius", "sweep"], + "sketch.slot.center_to_center" | "sketch.slot.overall" => &["length", "width"], + "sketch.polygon.inscribed" | "sketch.polygon.circumscribed" | "sketch.polygon.edge" => &["radius"], + _ => return Err(bad(cmd, "unsupported sketch creator")), + }; + let mut params = p.get("params").filter(|v| v.is_object()).cloned().ok_or_else(|| bad(cmd, "`params` must be an object"))?; + let dimensions = p.get("dimensions").and_then(Value::as_object).ok_or_else(|| bad(cmd, "`dimensions` must be an object"))?; + if dimensions.len() > roles.len() || dimensions.keys().any(|key| !roles.contains(&key.as_str())) { + return Err(bad(cmd, "unsupported dimension role for this creator")); + } + if dimensions.contains_key("radius") && dimensions.contains_key("diameter") { + return Err(bad(cmd, "choose radius or diameter, not both")); + } + let mut overall_length = None; + let mut slot_width = None; + for (role, value) in dimensions { + let e = expr(&json!({"value": value}), "value") + .filter(|e| e.len() <= 4096) + .ok_or_else(|| bad(cmd, "dimension values must be bounded expressions or finite numbers"))?; + let kind = if role == "angle" || role == "sweep" { Kind::Angle } else { Kind::Length }; + let evaluated = s.doc.eval(&e, kind)?; + if role == "length" { + overall_length = Some(evaluated); + } + if role == "width" { + slot_width = Some(evaluated); + } + if !evaluated.is_finite() || evaluated.abs() > 1e9 || kind == Kind::Length && evaluated <= 1e-9 { + return Err(bad(cmd, format!("invalid {role} dimension"))); + } + if role == "sweep" && !(evaluated.abs() > 1e-9 && evaluated.abs() < std::f64::consts::TAU - 1e-9) { + return Err(bad(cmd, "sweep must be nonzero and less than a full turn")); + } + if role == "sweep" { + params["sweep"] = json!(evaluated.to_degrees()); + if let Some(o) = params.as_object_mut() { + o.remove("end"); + } + } + } + if create == "sketch.slot.overall" + && let Some(length) = overall_length + && let Some(width) = slot_width.or_else(|| params.get("width").and_then(Value::as_f64)) + && length <= width + 1e-9 + { + return Err(bad(cmd, "overall slot length must exceed its width")); + } + if create == "sketch.line" { + if params.get("closed").and_then(Value::as_bool).unwrap_or(false) + || params.get("points").and_then(Value::as_array).is_none_or(|points| points.len() != 2) + { + return Err(bad(cmd, "dimensioned line creation needs exactly two points")); + } + if dimensions.contains_key("angle") { + params["infer"] = json!(false); + } + } + let mut scratch = s.scratch(); + let mut out = scratch.execute(create, ¶ms)?; + let curves = out.get("curves").and_then(Value::as_array).cloned().unwrap_or_default(); + let curve = |index: usize| -> Result { + curves.get(index).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return the expected curve")) + }; + let mut added = serde_json::Map::new(); + for (role, value) in dimensions { + let (entities, ty) = match (create, role.as_str()) { + ("sketch.line", "length") => (vec![curve(0)?], "length"), + ("sketch.line", "angle") => (vec![curve(0)?], "angle"), + (c, "width" | "height") if c.starts_with("sketch.rectangle.") => (vec![curve(usize::from(role == "height"))?], "length"), + (c, "radius" | "diameter") if c.starts_with("sketch.circle.") => (vec![curve(0)?], role.as_str()), + ("sketch.arc.center_point", "radius" | "sweep") => (vec![curve(0)?], role.as_str()), + ("sketch.slot.center_to_center", "length") => (vec![curve(0)?], "length"), + ("sketch.slot.overall", "length") => (vec![curve(1)?, curve(3)?], "overall_length"), + ("sketch.slot.center_to_center" | "sketch.slot.overall", "width") => (vec![curve(1)?], "diameter"), + (c, "radius") if c.starts_with("sketch.polygon.") => { + let support = + out.get("support_circle").filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "polygon support circle missing"))?; + (vec![support], "radius") + } + _ => return Err(bad(cmd, "unsupported dimension role")), + }; + let mut dimension = json!({"entities": entities, "type": ty, "value": value}); + if let Some(sketch) = params.get("sketch") { + dimension["sketch"] = sketch.clone(); + } + let result = scratch.execute("sketch.dimension", &dimension)?; + added.insert(role.clone(), result); + } + // Session::execute records the wrapper's before snapshot. Nested history must not leak. + scratch.undo.clear(); + s.accept_computed(scratch); + out["dimensions"] = Value::Object(added); + Ok(out) +} + +#[cfg(test)] +#[path = "sketch_dimensioned_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/sketch_dimensioned_tests.rs b/crates/engine/src/cmd/sketch_dimensioned_tests.rs new file mode 100644 index 00000000..7161c63f --- /dev/null +++ b/crates/engine/src/cmd/sketch_dimensioned_tests.rs @@ -0,0 +1,167 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_sketch::ConstraintKind; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}")) +} +fn session() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + s +} +fn sketch(s: &Session) -> solvecraft_sketch::Sketch { + s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone() +} +fn draw(s: &mut Session, command: &str, params: Value, dimensions: Value) -> Value { + run(s, "sketch.draw.dimensioned", json!({"command":command,"params":params,"dimensions":dimensions})) +} +fn close(a: f64, b: f64) { + assert!((a - b).abs() < 1e-6, "{a} != {b}"); +} +fn measure(s: &Session, kind: fn(&ConstraintKind) -> bool) -> f64 { + let sk = sketch(s); + let c = sk.constraints.iter().find(|c| kind(&c.kind)).unwrap(); + solvecraft_sketch::measure_dimension(&sk, &c.kind).unwrap() +} + +#[test] +fn typed_line_angle_survives_quadrants_move_reopen_and_one_undo() { + let mut s = session(); + run(&mut s, "parameters.add", json!({"name":"heading","expression":"-45 deg","unit":"deg"})); + let undo = s.undo.len(); + let out = draw(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]],"infer":true}), json!({"length":"10 mm","angle":"heading"})); + assert_eq!(s.undo.len(), undo + 1); + assert!(!sketch(&s).constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Horizontal { .. } | ConstraintKind::Vertical { .. }))); + close(measure(&s, |k| matches!(k, ConstraintKind::LineAngle { .. })), -45f64.to_radians()); + for degrees in [135.0_f64, 270.0, -135.0, 0.0, 90.0] { + let result = run(&mut s, "parameters.change", json!({"name":"heading","expression":format!("{degrees} deg")})); + assert_eq!(result["errors"], json!([])); + close(measure(&s, |k| matches!(k, ConstraintKind::LineAngle { .. })), degrees.to_radians()); + close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. })), 10.0); + } + let encoded = serde_json::to_value(&*s.doc).unwrap(); + let doc: solvecraft_doc::Document = serde_json::from_value(encoded).unwrap(); + let mut reopened = Session { doc: std::sync::Arc::new(doc), active_sketch: s.active_sketch, ..Session::default() }; + reopened.refresh(); + close(measure(&reopened, |k| matches!(k, ConstraintKind::LineAngle { .. })), 90f64.to_radians()); + run(&mut reopened, "sketch.move", json!({"entities":out["curves"],"translate":[4,3],"angle":20})); + close(measure(&reopened, |k| matches!(k, ConstraintKind::LineAngle { .. })), 90f64.to_radians()); + let mut atomic = session(); + draw(&mut atomic, "sketch.line", json!({"points":[[0,0],[5,5]]}), json!({"length":10,"angle":45})); + run(&mut atomic, "edit.undo", json!({})); + assert!(sketch(&atomic).curves.is_empty()); + assert!(atomic.doc.params.iter().all(|p| !p.model)); +} + +#[test] +fn signed_arc_sweep_retains_radius_and_logical_endpoint_identity() { + let mut s = session(); + run(&mut s, "parameters.add", json!({"name":"turn","expression":"-90 deg","unit":"deg"})); + let out = draw(&mut s, "sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,-10]}), json!({"radius":10,"sweep":"turn"})); + let id = out["curves"][0].as_str().unwrap().to_string(); + let original = sketch(&s).resolve_point(&format!("{id}.start")).unwrap(); + for degrees in [-90.0_f64, -270.0, 90.0, 270.0, -45.0] { + let result = run(&mut s, "parameters.change", json!({"name":"turn","expression":format!("{degrees} deg")})); + assert_eq!(result["errors"], json!([])); + let sk = sketch(&s); + assert_eq!(sk.resolve_point(&format!("{id}.start")), Some(original)); + assert_eq!(sk.curves[sk.curve_index(&id).unwrap()].reversed, degrees < 0.0); + close(measure(&s, |k| matches!(k, ConstraintKind::ArcSweep { .. })), degrees.to_radians()); + close(measure(&s, |k| matches!(k, ConstraintKind::Radius { .. })), 10.0); + } +} + +#[test] +fn slots_and_polygon_keep_permanent_typed_dimensions() { + for command in ["sketch.slot.center_to_center", "sketch.slot.overall"] { + let mut s = session(); + let out = draw(&mut s, command, json!({"p0":[2,3],"p1":[22,3],"width":4,"connect":false}), json!({"length":20,"width":4})); + let length = out["dimensions"]["length"]["param"].as_str().unwrap(); + let result = run(&mut s, "parameters.change", json!({"name":length,"expression":30})); + assert_eq!(result["errors"], json!([])); + close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::SlotOverallLength { .. })), 30.0); + let width = out["dimensions"]["width"]["param"].as_str().unwrap(); + let result = run(&mut s, "parameters.change", json!({"name":width,"expression":6})); + assert_eq!(result["errors"], json!([])); + close(measure(&s, |k| matches!(k, ConstraintKind::Diameter { .. })), 6.0); + close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::SlotOverallLength { .. })), 30.0); + } + for command in ["sketch.polygon.inscribed", "sketch.polygon.circumscribed"] { + let mut s = session(); + let out = draw(&mut s, command, json!({"center":[4,5],"radius":10,"sides":6,"connect":false}), json!({"radius":10})); + let support = out["support_circle"].as_str().unwrap(); + assert!(sketch(&s).curves[sketch(&s).curve_index(support).unwrap()].construction); + let radius = out["dimensions"]["radius"]["param"].as_str().unwrap(); + let result = run(&mut s, "parameters.change", json!({"name":radius,"expression":15})); + assert_eq!(result["errors"], json!([])); + close(measure(&s, |k| matches!(k, ConstraintKind::Radius { .. })), 15.0); + } +} + +#[test] +fn invalid_dimensioned_shapes_are_atomic_including_late_constraint_failure() { + let mut s = session(); + let before = serde_json::to_value(&*s.doc).unwrap(); + let history = s.undo.len(); + for params in [ + json!({"command":"edit.undo","params":{},"dimensions":{}}), + json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":"missing+1"}}), + json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":-1}}), + json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":"1".repeat(4097)}}), + json!({"command":"sketch.arc.center_point","params":{"center":[0,0],"start":[10,0]},"dimensions":{"sweep":360}}), + json!({"command":"sketch.polygon.inscribed","params":{"center":[0,0],"radius":10,"sides":3.5},"dimensions":{"radius":10}}), + json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":20,"angle":0}}), + ] { + // The last case connects both endpoints to fixed existing points and fails only after creation. + if params["dimensions"]["length"] == 20 { + run(&mut s, "sketch.point", json!({"point":[10,0]})); + run(&mut s, "sketch.constraint.fix", json!({"entity":"p1","fixed":true})); + } + let snapshot = serde_json::to_value(&*s.doc).unwrap(); + let undo = s.undo.len(); + // Preserve a live redo branch, which scratch-only dimension failure must not consume. + run(&mut s, "sketch.point", json!({"point":[100,100]})); + run(&mut s, "edit.undo", json!({})); + s.selection = vec![crate::Sel::SketchPoint { id: "origin".into() }]; + let redo = s.redo.len(); + let selection = s.selection.clone(); + let pending = s.pending_moves.clone(); + let active = (s.active_sketch, s.active_component, s.active_occurrence_path.clone()); + assert!(s.execute("sketch.draw.dimensioned", ¶ms).is_err(), "{params}"); + assert_eq!(serde_json::to_value(&*s.doc).unwrap(), snapshot); + assert_eq!(s.undo.len(), undo); + assert_eq!(s.redo.len(), redo); + assert_eq!(s.selection, selection); + assert_eq!(s.pending_moves, pending); + assert_eq!((s.active_sketch, s.active_component, s.active_occurrence_path.clone()), active); + } + assert_eq!(before["features"].as_array().unwrap().len(), 1); + assert!(s.undo.len() >= history); +} + +#[test] +fn dimensioned_authoring_uses_selected_occurrence_and_keeps_local_geometry() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap(); + let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0],"operation":"new"})); + s.auto_project = false; + run(&mut s, "sketch.create", json!({"plane":{"face":[21,1,2]}})); + let out = draw(&mut s, "sketch.line", json!({"points":[[0,0],[5,5]]}), json!({"length":5,"angle":45})); + let model = s.model.state(); + let local = model.sketch(s.active_sketch.unwrap()).unwrap(); + close(local.plane.origin.x, 0.0); + close(local.plane.origin.z, 2.0); + let world = s.world_state(); + let displayed = world.sketch(s.active_sketch.unwrap()).unwrap(); + close(displayed.plane.origin.x, 20.0); + close(displayed.plane.origin.z, 2.0); + assert_eq!(s.active_occurrence_path, Some(vec![second])); + let sk = sketch(&s); + let line = sk.curve_index(out["curves"][0].as_str().unwrap()).unwrap(); + let solvecraft_sketch::CurveKind::Line { a, b } = sk.curves[line].kind else { panic!("line") }; + close(sk.points[a].pos.x, 0.0); + close(sk.points[b].pos.x, 5.0 / std::f64::consts::SQRT_2); +} diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index 67501f4d..a0a565be 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -88,38 +88,20 @@ struct Frames { fn frames(s: &Session) -> Frames { let st = s.model.state(); - let doc: std::borrow::Cow = if s.pending_moves.is_empty() { - std::borrow::Cow::Borrowed(&*s.doc) - } else { - let mut d = (*s.doc).clone(); - for o in &mut d.occurrences { - if let Some(m) = s.pending_moves.get(&o.id) { - o.transform = *m; - } - } - std::borrow::Cow::Owned(d) - }; + let doc = s.placed_document(); // World name, the component's own body name, and its frame. - let bodies = doc - .body_frames(&st) - .into_iter() - .map(|(world, _, _, m)| { - let own = st - .bodies - .iter() - .find(|b| world == b.name || world.starts_with(&format!("{} (", b.name))) - .map(|b| b.name.clone()) - .unwrap_or_else(|| world.clone()); - (world, own, m) - }) - .collect(); + let bodies = doc.body_frames_with_source(&st).into_iter().map(|(world, own, _, _, m)| (world, own, m)).collect(); let sketch_comp = s.active_sketch.and_then(|id| doc.feature(id)).map(|f| f.component).unwrap_or(s.active_component); - Frames { bodies, active: doc.component_transform(s.active_component), sketch: doc.component_transform(sketch_comp) } + let active = s.active_component_frame(); + Frames { bodies, active, sketch: if sketch_comp == s.active_component { active } else { doc.component_transform(sketch_comp) } } } -/// Does any placement move anything (so the world differs from the components' frames)? +/// Does world geometry or its instance naming differ from source components? pub fn placed(s: &Session) -> bool { - !s.pending_moves.is_empty() || !s.doc.body_offsets.is_empty() || s.doc.placements().iter().any(|(_, _, m)| !is_identity(m)) + let mut components = std::collections::BTreeSet::new(); + !s.pending_moves.is_empty() + || !s.doc.body_offsets.is_empty() + || s.doc.occurrences.iter().any(|o| !is_identity(&o.transform) || !components.insert(o.component)) } fn vec3(v: &Value) -> Option { @@ -138,7 +120,7 @@ fn pt(p: Vec3) -> Value { /// The frame of the body a world point lies on (the nearest), or of the named body. fn frame_at(s: &Session, f: &Frames, p: Vec3, body: Option<&str>) -> Option { if let Some(b) = body - && let Some((_, _, m)) = f.bodies.iter().find(|(w, own, _)| w == b || (own == b && !w.contains(" ("))) + && let Some((_, _, m)) = f.bodies.iter().find(|(w, _, _)| w == b) { return Some(*m); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6907769..c3acc13e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod frames; pub mod fuzz; pub mod legacy_ids; pub mod licences; +pub mod occurrences; pub mod params; pub mod recovery; pub mod sample; @@ -74,6 +75,10 @@ pub type Result = std::result::Result; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Sel { + /// An exact placement, including its containing occurrence placements. + Occurrence { + path: Vec, + }, Body { name: String, }, @@ -159,9 +164,12 @@ pub struct Session { pub auto_project: bool, /// The component new sketches and features go into (0 = the root). pub active_component: u64, + /// The placement used when authoring shared component geometry. + pub active_occurrence_path: Option>, /// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them). pub pending_moves: std::collections::BTreeMap, world_cache: std::sync::Mutex)>>, + occurrence_visibility_cache: std::sync::Mutex>, /// Features copied with `timeline.copy` (pasted by `timeline.paste`). pub clipboard: Vec, /// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden). @@ -192,6 +200,8 @@ pub(crate) struct Checkpoint { pub struct Visibility { /// Bodies by name. pub hidden_bodies: Vec, + /// Exact occurrence placements hidden with all descendants. + pub hidden_occurrences: Vec>, /// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name. pub hidden_origin: Vec, /// Sketches by feature id. @@ -246,8 +256,10 @@ impl Session { revision: 1, auto_project: true, active_component: 0, + active_occurrence_path: None, pending_moves: Default::default(), world_cache: Default::default(), + occurrence_visibility_cache: Default::default(), clipboard: Vec::new(), section: None, analysis: None, @@ -295,7 +307,7 @@ impl Session { { return w.clone(); } - let w = if self.pending_moves.is_empty() { + let mut w = if self.pending_moves.is_empty() { solvecraft_doc::world_state(&self.doc, &st) } else { let mut d = (*self.doc).clone(); @@ -306,6 +318,23 @@ impl Session { } solvecraft_doc::world_state(&d, &st) }; + // Sketch definitions are displayed once, at the placement currently being authored. + if self.active_occurrence_path.is_some() && self.active_component != 0 { + let first = self.occurrence_placement(&self.doc.first_path(self.active_component)).map(|p| p.world).unwrap_or(solvecraft_doc::IDENTITY); + if let Some(inverse) = solvecraft_doc::mat_inverse(&first) { + let delta = solvecraft_doc::mat_mul(&self.active_component_frame(), &inverse); + for sketch in w.sketches.iter_mut().filter(|sk| self.doc.feature(sk.feature).is_some_and(|f| f.component == self.active_component)) { + let plane = sketch.plane; + if let Some(mapped) = solvecraft_geom::Plane::new( + solvecraft_doc::apply_point(&delta, plane.origin), + solvecraft_doc::apply_vector(&delta, plane.x), + solvecraft_doc::apply_vector(&delta, plane.y), + ) { + sketch.plane = mapped; + } + } + } + } let w = Arc::new(w); if let Ok(mut c) = self.world_cache.lock() { *c = Some((self.revision, w.clone())); @@ -316,6 +345,7 @@ impl Session { pub fn refresh(&mut self) { self.model.evaluate(&self.doc); self.revision += 1; + self.prune_occurrence_state(); // Joints follow the geometry their origins snap to. if !self.doc.assembly.joints.is_empty() { cmd::joints::resolve(self); @@ -421,21 +451,23 @@ impl Session { undo: Vec::new(), redo: Vec::new(), active_sketch: self.active_sketch, - selection: Vec::new(), + selection: self.selection.clone(), path: None, saved: self.saved.clone(), log: Vec::new(), revision: self.revision, auto_project: self.auto_project, active_component: self.active_component, + active_occurrence_path: self.active_occurrence_path.clone(), pending_moves: self.pending_moves.clone(), world_cache: Default::default(), + occurrence_visibility_cache: Default::default(), clipboard: self.clipboard.clone(), section: None, analysis: None, checkpoints: Vec::new(), next_checkpoint: 1, - visibility: Visibility::default(), + visibility: self.visibility.clone(), } } @@ -469,6 +501,7 @@ impl Session { self.revision = computed.revision; self.invalidate_view(previous_revision); self.active_component = computed.active_component; + self.active_occurrence_path = computed.active_occurrence_path; self.pending_moves = computed.pending_moves; for message in computed.log { self.echo(message); diff --git a/crates/engine/src/occurrences.rs b/crates/engine/src/occurrences.rs new file mode 100644 index 00000000..136a158d --- /dev/null +++ b/crates/engine/src/occurrences.rs @@ -0,0 +1,216 @@ +//! Exact placement identity and session view/authoring state. +use crate::{EngineError, Result, Sel, Session}; +use solvecraft_doc::{IDENTITY, Mat, mat_mul}; + +#[derive(Clone, Debug)] +pub struct OccurrenceBody { + pub source: String, + pub world: String, +} + +#[derive(Clone, Debug)] +pub struct OccurrencePlacement { + pub occurrence: u64, + pub component: u64, + pub path: Vec, + pub name: String, + pub world: Mat, + pub local: Mat, + pub parent_world: Mat, + pub grounded: bool, + pub bodies: Vec, +} + +fn invalid(message: &str) -> EngineError { + EngineError::Other(message.to_string()) +} + +pub(crate) struct VisibilityCache { + revision: u64, + paths: Vec>, + bodies: std::collections::BTreeSet, +} + +impl Session { + /// Document with uncaptured occurrence positions applied, without changing saved geometry. + pub fn placed_document(&self) -> std::borrow::Cow<'_, solvecraft_doc::Document> { + if self.pending_moves.is_empty() { + return std::borrow::Cow::Borrowed(&self.doc); + } + let mut doc = (*self.doc).clone(); + for o in &mut doc.occurrences { + if let Some(transform) = self.pending_moves.get(&o.id) { + o.transform = *transform; + } + } + std::borrow::Cow::Owned(doc) + } + /// Resolve only the supplied path, never an exponentially expanded assembly. + pub fn occurrence_placement(&self, path: &[u64]) -> Result { + if path.is_empty() || path.len() > 64 { + return Err(invalid("occurrence path must contain 1..64 ids")); + } + let mut component = 0; + let mut world = IDENTITY; + let mut parent_world = IDENTITY; + let mut seen = std::collections::BTreeSet::new(); + for id in path { + if !seen.insert(*id) { + return Err(invalid("occurrence path repeats an id")); + } + let o = self + .doc + .occurrences + .iter() + .find(|o| o.id == *id && o.parent == component) + .ok_or_else(|| invalid("occurrence path has a missing or mismatched parent"))?; + if !self.doc.components.iter().any(|c| c.id == o.component) { + return Err(invalid("occurrence path has a missing component")); + } + parent_world = world; + world = mat_mul(&world, self.pending_moves.get(id).unwrap_or(&o.transform)); + if world.iter().flatten().any(|x| !x.is_finite()) { + return Err(invalid("occurrence path has a non-finite transform")); + } + component = o.component; + } + let leaf = self.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).ok_or_else(|| invalid("occurrence path has no leaf"))?; + let state = self.model.state(); + let bodies = state + .bodies + .iter() + .filter(|b| self.doc.body_component(&b.name, b.feature) == component) + .map(|b| OccurrenceBody { source: b.name.clone(), world: self.doc.world_body_name(&state, &b.name, component, path) }) + .collect(); + Ok(OccurrencePlacement { + occurrence: leaf.id, + component, + path: path.to_vec(), + name: leaf.name.clone(), + world, + local: self.pending_moves.get(&leaf.id).copied().unwrap_or(leaf.transform), + parent_world, + grounded: leaf.grounded, + bodies, + }) + } + + /// Children are definitions shared by every placement of the parent component. + pub fn occurrence_children(&self, path: &[u64]) -> Result> { + let component = if path.is_empty() { 0 } else { self.occurrence_placement(path)?.component }; + self.doc + .occurrences + .iter() + .filter(|o| o.parent == component) + .map(|o| { + let mut child = path.to_vec(); + child.push(o.id); + self.occurrence_placement(&child) + }) + .collect() + } + + /// Bounded descendant expansion. Never return an incomplete selection or bounds list. + pub fn occurrence_bodies(&self, path: &[u64], descendants: bool) -> Result> { + let mut stack = vec![self.occurrence_placement(path)?]; + let mut out = Vec::new(); + let mut count = 0; + while let Some(placement) = stack.pop() { + count += 1; + if count > 100_000 || out.len() + placement.bodies.len() > 100_000 { + return Err(invalid("occurrence expansion exceeds 100000 placements or bodies")); + } + out.extend(placement.bodies.into_iter().map(|b| b.world)); + if descendants { + stack.extend(self.occurrence_children(&placement.path)?); + } + } + Ok(out) + } + + /// Selected authoring placement, or the compatible first-placement frame. + pub fn active_component_frame(&self) -> Mat { + if let Some(path) = &self.active_occurrence_path + && let Ok(p) = self.occurrence_placement(path) + && p.component == self.active_component + { + return p.world; + } + let path = self.doc.first_path(self.active_component); + self.occurrence_placement(&path).map(|p| p.world).unwrap_or(IDENTITY) + } + + pub fn occurrence_visible(&self, path: &[u64]) -> bool { + !self.visibility.hidden_occurrences.iter().any(|hidden| path.starts_with(hidden)) + } + + pub fn occurrence_hidden(&self, path: &[u64]) -> bool { + self.visibility.hidden_occurrences.iter().any(|hidden| hidden == path) + } + + /// Number of placements affected by editing this shared occurrence definition. + pub fn occurrence_scope_count(&self, path: &[u64]) -> Result { + let id = self.occurrence_placement(path)?.occurrence; + self.occurrence_id_scope_count(id) + } + + pub fn occurrence_id_scope_count(&self, id: u64) -> Result { + if !self.doc.occurrences.iter().any(|o| o.id == id) { + return Err(invalid("no occurrence")); + } + let mut stack = vec![(0, 0usize)]; + let mut visited = 0; + let mut count = 0; + while let Some((component, depth)) = stack.pop() { + for o in self.doc.occurrences.iter().filter(|o| o.parent == component) { + visited += 1; + if depth >= 64 || visited > 100_000 { + return Err(invalid("occurrence scope exceeds 64 levels or 100000 placements")); + } + if o.id == id { + count += 1; + } + stack.push((o.component, depth + 1)); + } + } + Ok(count) + } + + pub fn body_visible(&self, name: &str) -> bool { + if self.visibility.hidden_bodies.iter().any(|n| n == name) { + return false; + } + if self.visibility.hidden_occurrences.is_empty() { + return true; + } + if let Ok(mut cache) = self.occurrence_visibility_cache.lock() { + if cache.as_ref().is_none_or(|c| c.revision != self.revision || c.paths != self.visibility.hidden_occurrences) { + let bodies = self + .doc + .body_frames(&self.model.state()) + .into_iter() + .filter(|(_, _, path, _)| !self.occurrence_visible(path)) + .map(|(name, _, _, _)| name) + .collect(); + *cache = Some(VisibilityCache { revision: self.revision, paths: self.visibility.hidden_occurrences.clone(), bodies }); + } + return cache.as_ref().is_none_or(|c| !c.bodies.contains(name)); + } + self.doc.body_frames(&self.model.state()).iter().find(|(n, _, _, _)| n == name).is_none_or(|(_, _, path, _)| self.occurrence_visible(path)) + } + + pub(crate) fn prune_occurrence_state(&mut self) { + self.pending_moves.retain(|id, _| self.doc.occurrences.iter().any(|o| o.id == *id)); + if self.active_component != 0 && !self.doc.components.iter().any(|c| c.id == self.active_component) { + self.active_component = 0; + self.active_occurrence_path = None; + } + if self.active_occurrence_path.as_ref().is_some_and(|p| self.occurrence_placement(p).map_or(true, |p| p.component != self.active_component)) { + self.active_occurrence_path = None; + } + let selection = std::mem::take(&mut self.selection); + self.selection = selection.into_iter().filter(|s| !matches!(s, Sel::Occurrence {path} if self.occurrence_placement(path).is_err())).collect(); + let hidden = std::mem::take(&mut self.visibility.hidden_occurrences); + self.visibility.hidden_occurrences = hidden.into_iter().filter(|p| self.occurrence_placement(p).is_ok()).collect(); + } +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 52b3a675..05eab29d 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -42,7 +42,7 @@ pub fn bounds(s: &Session) -> Aabb3 { let st = s.world_state(); let mut b = Aabb3::EMPTY; let v = &s.visibility; - for body in st.bodies.iter().filter(|x| !v.hidden_bodies.contains(&x.name)) { + for body in st.bodies.iter().filter(|x| s.body_visible(&x.name)) { b = b.union(&body.mesh().bounds()); } for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) { @@ -152,8 +152,17 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false }); } - for body in st.bodies.iter().filter(|b| !s.visibility.hidden_bodies.contains(&b.name)) { - let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name)); + let selected_occurrences: Vec = s + .selection + .iter() + .filter_map(|x| match x { + Sel::Occurrence { path } => s.occurrence_bodies(path, true).ok(), + _ => None, + }) + .flatten() + .collect(); + for body in st.bodies.iter().filter(|b| s.body_visible(&b.name)) { + let selected = selected_occurrences.contains(&body.name) || s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name)); let mesh = body.mesh(); // The body's appearance (or imported colour) and faces with looks of their own, as in // the viewport. diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 41278c21..eafac71e 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -5,6 +5,9 @@ use truck_modeling as mt; use crate::{KernelError, Result, guard}; +#[path = "shell_groups.rs"] +mod shell_groups; + pub(crate) type Solid = mt::Solid; /// A solid body (one or more closed shells), or a mesh body (triangles only, like an imported @@ -674,51 +677,97 @@ impl Body { /// The separate pieces of a body: each outer shell with the voids inside it. A body in one /// piece comes back as itself. pub fn lumps(&self) -> Result> { + self.lumps_with_clearance(0.0) + } + + /// A boolean shortcut needs clearance larger than both shell mesh deflections. + pub(crate) fn separated_lumps(&self) -> Result> { + self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3)) + } + + fn lumps_with_clearance(&self, clearance: f64) -> Result> { if self.mesh.is_some() || self.solid.boundaries().len() < 2 { return Ok(vec![self.clone()]); } let tol = (self.size() * 1e-3).max(1e-3); guard("lumps", || { let shells: Vec = self.solid.boundaries().clone(); - let meshes: Vec<(f64, Option)> = shells + if shells.len() > shell_groups::MAX_SHELLS + || shells.iter().flat_map(mt::Shell::vertex_iter).take(shell_groups::MAX_POINTS + 1).count() > shell_groups::MAX_POINTS + || shells.iter().flat_map(mt::Shell::face_iter).take(shell_groups::MAX_TRIANGLES + 1).count() > shell_groups::MAX_TRIANGLES + { + return Err(KernelError::Failed("lumps: topology exceeds the shell grouping limit".into())); + } + let (mut point_count, mut triangle_count) = (0usize, 0usize); + let data: Vec<(f64, Mesh)> = shells .iter() .map(|sh| { - let pm = sh.robust_triangulation(tol).to_polygon(); - let v = pm.volume(); + let meshed = sh.robust_triangulation(tol); + if meshed.face_iter().any(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())) { + return Err(KernelError::Failed("lumps: cannot mesh a complete shell boundary".into())); + } + let pm = meshed.to_polygon(); + point_count = point_count + .checked_add(pm.positions().len()) + .ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?; + triangle_count = triangle_count + .checked_add(pm.faces().triangle_iter().take(shell_groups::MAX_TRIANGLES + 1).count()) + .ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?; + if point_count > shell_groups::MAX_POINTS || triangle_count > shell_groups::MAX_TRIANGLES { + return Err(KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into())); + } let mut m = Mesh::default(); for p in pm.positions() { m.positions.push(Vec3::new(p.x, p.y, p.z)); } for t in pm.faces().triangle_iter() { - let idx = |k: usize| t.get(k).map(|x| u32::try_from(x.pos).unwrap_or(0)).unwrap_or(0); - m.triangles.push([idx(0), idx(1), idx(2)]); + let idx = |k: usize| { + t.get(k).and_then(|x| u32::try_from(x.pos).ok()).ok_or_else(|| KernelError::Failed("lumps: invalid mesh index".into())) + }; + let indices = [idx(0)?, idx(1)?, idx(2)?]; + let [a, b, c] = m.tri(&indices).ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))?; + // STEP sphere poles can produce zero-area duplicate triangles. + // They have no boundary area or volume and are not ray crossings. + if (b - a).cross(c - a) == Vec3::ZERO { + continue; + } + m.triangles.push(indices); m.tri_face.push(0); } - (v, Some(m)) + let origin = m.positions.first().copied().ok_or_else(|| KernelError::Failed("lumps: empty mesh".into()))?; + let v = m + .triangles + .iter() + .map(|t| { + m.tri(t) + .map(|[a, b, c]| (a - origin).dot((b - origin).cross(c - origin)) / 6.0) + .ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into())) + }) + .collect::>>()? + .into_iter() + .sum(); + Ok((v, m)) }) - .collect(); - let outer: Vec = (0..shells.len()).filter(|i| meshes.get(*i).is_some_and(|m| m.0 > 0.0)).collect(); - if outer.len() < 2 { + .collect::>()?; + let (volumes, meshes): (Vec<_>, Vec<_>) = data.into_iter().unzip(); + let groups = shell_groups::groups(&meshes, &volumes, (self.size() * 1e-8).max(1e-9).max(clearance))?; + if groups.len() == 1 { return Ok(vec![self.clone()]); } - let mut groups: Vec> = outer.iter().filter_map(|i| shells.get(*i).cloned()).map(|s| vec![s]).collect(); - for (i, sh) in shells.iter().enumerate() { - if outer.contains(&i) { - continue; - } - // A void goes with the outer shell around one of its points. - let probe = sh.vertex_iter().next().map(|v| from_p3(v.point())); - let owner = outer.iter().position(|o| { - let (Some(p), Some((_, Some(m)))) = (probe, meshes.get(*o)) else { return false }; - m.contains(p) - }); - if let Some(k) = owner - && let Some(g) = groups.get_mut(k) - { - g.push(sh.clone()); - } - } - groups.into_iter().map(|g| Solid::try_new(g).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)).collect() + groups + .into_iter() + .map(|g| { + let shells = g + .into_iter() + .map(|i| shells.get(i).cloned().ok_or_else(|| KernelError::Failed("lumps: missing shell".into()))) + .collect::>>()?; + Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new) + }) + .collect() }) } } + +#[cfg(test)] +#[path = "body_lump_tests.rs"] +mod lump_tests; diff --git a/crates/kernel/src/body_lump_tests.rs b/crates/kernel/src/body_lump_tests.rs new file mode 100644 index 00000000..722ef31a --- /dev/null +++ b/crates/kernel/src/body_lump_tests.rs @@ -0,0 +1,224 @@ +use super::*; +use crate::{BoolOp, boolean, box_solid, measure, sphere, step_export, step_import}; + +fn cube(half: f64) -> Body { + box_solid(Vec3::new(-half, -half, -half), Vec3::new(half, half, half)).unwrap() +} + +fn nested_island() -> Body { + let hollow = boolean(&cube(50.0), &cube(40.0), BoolOp::Cut).unwrap().unwrap(); + // This order puts the containing outer shell ahead of the disconnected island. + let joined = boolean(&cube(20.0), &hollow, BoolOp::Union).unwrap().unwrap(); + boolean(&joined, &cube(10.0), BoolOp::Cut).unwrap().unwrap() +} + +fn assert_material(body: &Body, expected: f64, probes: &[(Vec3, bool)]) { + assert!(body.validity().is_empty(), "{:?}", body.validity()); + let mesh = body.tessellate_with(0.01, true).unwrap(); + assert!((mesh.measure().volume - expected).abs() < expected * 1e-6, "{} vs {expected}", mesh.measure().volume); + for (p, want) in probes { + assert_eq!(mesh.contains(*p), *want, "material at {p:?}"); + } +} + +fn check_nested(body: &Body) { + let probes = [(Vec3::ZERO, false), (Vec3::X * 15.0, true), (Vec3::X * 30.0, false), (Vec3::X * 45.0, true)]; + assert_material(body, 544_000.0, &probes); + let lumps = body.lumps().unwrap(); + assert_eq!(lumps.len(), 2); + let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect(); + volumes.sort_by(f64::total_cmp); + for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) { + assert!((got - want).abs() < 1e-6, "{volumes:?}"); + } + let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect(); + for (p, want) in probes { + assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), usize::from(want), "lump material at {p:?}"); + } +} + +#[test] +fn nested_cavities_stay_with_their_material_islands_for_every_shell_order() { + let body = nested_island(); + check_nested(&body); + let before = step_export(&[&body], "nested cavities").unwrap(); + let shells = body.deep_copy().boundaries().clone(); + assert_eq!(shells.len(), 4); + for a in 0..4 { + for b in 0..4 { + for c in 0..4 { + for d in 0..4 { + let order = [a, b, c, d]; + if order.iter().enumerate().any(|(i, x)| order.iter().skip(i + 1).any(|y| x == y)) { + continue; + } + let permuted = guard("permuted cavity shells", || { + Body::new(Solid::try_new(order.into_iter().map(|i| shells[i].clone()).collect()).unwrap()) + }) + .unwrap(); + check_nested(&permuted); + } + } + } + } + assert_eq!(step_export(&[&body], "nested cavities").unwrap(), before); +} + +#[test] +fn nested_voids_survive_step_round_trip_and_further_cuts() { + let body = nested_island(); + let text = step_export(&[&body], "nested cavities").unwrap(); + let imported = step_import(&text).unwrap(); + assert!(imported.warnings.is_empty(), "{:?}", imported.warnings); + let mut lumps = Vec::new(); + for imported in imported.bodies { + lumps.extend(imported.body.lumps().unwrap()); + } + assert_eq!(lumps.len(), 2); + let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect(); + volumes.sort_by(f64::total_cmp); + for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) { + assert!((got - want).abs() < 1e-3, "{volumes:?}"); + } + let opening = box_solid(Vec3::new(5.0, -2.0, -2.0), Vec3::new(25.0, 2.0, 2.0)).unwrap(); + let mut cut_volume = 0.0; + for lump in &lumps { + if let Some(cut) = boolean(lump, &opening, BoolOp::Cut).unwrap() { + cut_volume += measure(&cut).unwrap().volume; + assert!(!cut.tessellate_with(0.01, true).unwrap().contains(Vec3::X * 15.0)); + } + } + // Only x=10..20 is material along this 4×4 opening: 160 mm³ removed. + assert!((cut_volume - 543_840.0).abs() < 1e-3, "{cut_volume}"); +} + +#[test] +fn curved_internal_void_and_disconnected_material_keep_analytic_volume() { + let hollow = boolean(&cube(20.0), &sphere(Vec3::ZERO, 10.0).unwrap(), BoolOp::Cut).unwrap().unwrap(); + let island = sphere(Vec3::ZERO, 4.0).unwrap(); + let joined = boolean(&island, &hollow, BoolOp::Union).unwrap().unwrap(); + let reverse = boolean(&hollow, &island, BoolOp::Union).unwrap().unwrap(); + assert!((measure(&joined).unwrap().volume - measure(&reverse).unwrap().volume).abs() < 1e-4); + let body = boolean(&joined, &sphere(Vec3::ZERO, 2.0).unwrap(), BoolOp::Cut).unwrap().unwrap(); + let lumps = body.lumps().unwrap(); + assert_eq!(lumps.len(), 2); + let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect(); + volumes.sort_by(f64::total_cmp); + let pi = std::f64::consts::PI; + let want = [4.0 / 3.0 * pi * (64.0 - 8.0), 64_000.0 - 4.0 / 3.0 * pi * 1000.0]; + for (got, want) in volumes.iter().zip(want) { + assert!((got - want).abs() < want * 0.002, "{got} vs {want}"); + } + let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect(); + for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] { + assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), want); + } + let imported = step_import(&step_export(&[&body], "curved nested cavities").unwrap()).unwrap(); + assert!(imported.warnings.is_empty(), "{:?}", imported.warnings); + let imported: Vec<_> = imported.bodies.into_iter().flat_map(|b| b.body.lumps().unwrap()).collect(); + assert_eq!(imported.len(), 2); + let imported_meshes: Vec<_> = imported.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect(); + for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] { + assert_eq!(imported_meshes.iter().filter(|m| m.contains(p)).count(), want); + } +} + +fn joined_shells(shells: Vec) -> Body { + guard("invalid ownership fixture", || Body::new(Solid::try_new(shells).unwrap())).unwrap() +} + +#[test] +fn unowned_overlapping_and_touching_cavities_are_errors_not_dropped_geometry() { + let outer = cube(50.0); + for (min, max) in [ + (Vec3::new(60.0, 60.0, 60.0), Vec3::new(70.0, 70.0, 70.0)), + (Vec3::new(45.0, -5.0, -5.0), Vec3::new(55.0, 5.0, 5.0)), + (Vec3::new(40.0, -5.0, -5.0), Vec3::new(50.0, 5.0, 5.0)), + ] { + let mut inner = box_solid(min, max).unwrap().deep_copy(); + inner.not(); + let body = joined_shells(outer.deep_copy().boundaries().iter().chain(inner.boundaries()).cloned().collect()); + let before = step_export(&[&body], "invalid ownership").unwrap(); + assert!(body.lumps().is_err()); + assert_eq!(step_export(&[&body], "invalid ownership").unwrap(), before); + } + let mut a = box_solid(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0)).unwrap().deep_copy(); + a.not(); + let mut b = box_solid(Vec3::new(0.0, -10.0, -10.0), Vec3::new(20.0, 10.0, 10.0)).unwrap().deep_copy(); + b.not(); + let body = joined_shells(outer.deep_copy().boundaries().iter().chain(a.boundaries()).chain(b.boundaries()).cloned().collect()); + assert!(body.lumps().is_err()); +} + +#[test] +fn shell_grouping_preserves_large_disconnected_patterns_and_bounds_hostile_work() { + let mesh = cube(1.0).tessellate_with(0.01, true).unwrap(); + let copies: Vec<_> = (0..512) + .map(|i| { + let mut m = mesh.clone(); + for p in &mut m.positions { + p.x += f64::from(i) * 4.0; + } + m + }) + .collect(); + assert_eq!(shell_groups::groups(&copies, &vec![8.0; 512], 1e-8).unwrap().len(), 512); + let excessive = vec![Mesh::default(); shell_groups::MAX_SHELLS + 1]; + assert!(shell_groups::groups(&excessive, &vec![1.0; excessive.len()], 1e-8).unwrap_err().to_string().contains("shell count")); + let mut oversized = mesh.clone(); + oversized.positions.resize(shell_groups::MAX_POINTS + 1, Vec3::ZERO); + assert!(shell_groups::groups(&[oversized], &[8.0], 1e-8).unwrap_err().to_string().contains("grouping limit")); + let mut repeated = mesh.clone(); + repeated.triangles = mesh.triangles.iter().copied().cycle().take(7000).collect(); + assert!(shell_groups::groups(&[repeated.clone(), repeated], &[8.0, 8.0], 1e-8).unwrap_err().to_string().contains("boundary work")); + let mut outer = cube(2.0).tessellate_with(0.01, true).unwrap(); + let mut inner = mesh; + outer.triangles = outer.triangles.iter().copied().cycle().take(4000).collect(); + inner.triangles = inner.triangles.iter().copied().cycle().take(4000).collect(); + inner.positions.resize(4000, Vec3::ZERO); + assert!(shell_groups::groups(&[outer, inner], &[64.0, -8.0], 1e-8).unwrap_err().to_string().contains("containment work")); +} + +#[test] +fn translated_and_scaled_nested_voids_keep_material_membership() { + let body = nested_island(); + for scale in [0.01, 0.25, 2.0] { + let origin = Vec3::new(1234.0, -987.0, 765.0); + let transformed = crate::transform_matrix( + &body, + [[scale, 0.0, 0.0, 0.0], [0.0, scale, 0.0, 0.0], [0.0, 0.0, scale, 0.0], [origin.x, origin.y, origin.z, 1.0]], + ) + .unwrap(); + let lumps = transformed + .lumps() + .unwrap_or_else(|e| panic!("scale={scale}: {e}; bounds={:?}", transformed.tessellate_with(1e-4, true).unwrap().bounds())); + assert_eq!(lumps.len(), 2); + let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with((scale * 0.01).max(1e-5), true).unwrap()).collect(); + for (x, want) in [(0.0, 0), (15.0, 1), (30.0, 0), (45.0, 1)] { + assert_eq!(meshes.iter().filter(|m| m.contains(origin + Vec3::X * (x * scale))).count(), want); + } + } +} + +#[test] +fn shortcut_clearance_refuses_curved_contact_hidden_between_mesh_vertices() { + let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap(); + let a = sphere(Vec3::ZERO, 10.0).unwrap(); + for distance in [19.999, 20.001] { + let b = sphere(direction * distance, 10.0).unwrap(); + for shells in [ + a.deep_copy().boundaries().iter().chain(b.deep_copy().boundaries()).cloned().collect(), + b.deep_copy().boundaries().iter().chain(a.deep_copy().boundaries()).cloned().collect(), + ] { + assert!(joined_shells(shells).separated_lumps().is_err(), "distance {distance}"); + } + } + let far = sphere(direction * 21.0, 10.0).unwrap(); + let safe = crate::ops::join_pieces(vec![a, far]).unwrap().unwrap(); + assert_eq!(safe.separated_lumps().unwrap().len(), 2); + let outer = sphere(Vec3::ZERO, 10.0).unwrap(); + let mut protruding = sphere(direction * 9.001, 1.0).unwrap().deep_copy(); + protruding.not(); + let invalid = joined_shells(outer.deep_copy().boundaries().iter().chain(protruding.boundaries()).cloned().collect()); + assert!(invalid.separated_lumps().is_err()); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index f0e8e878..38558a9a 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -64,6 +64,7 @@ pub use offset::with_same_surface; pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix}; pub use polybool::{faceted_boolean, planar_boolean}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; +pub use prism::{extrude_planar_regions, planar_regions_boolean}; pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew}; pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map}; pub use step::step_export; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 045f48ba..10a10d01 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -114,6 +114,13 @@ fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solv const ALL: f64 = 0.997; let disjoint = b_in_a < NONE && a_in_b < NONE; match op { + // Sampling only selects this candidate. Validate every shell boundary and the + // complete material/void nesting before preserving a disconnected union. + BoolOp::Union if disjoint => { + let joined = join_pieces(vec![a.clone(), b.clone()]).ok().flatten()?; + joined.separated_lumps().ok()?; + Some(Some(joined)) + } BoolOp::Cut if disjoint => Some(Some(a.clone())), BoolOp::Cut if a_in_b > ALL => Some(None), BoolOp::Intersect if disjoint => Some(None), @@ -122,31 +129,34 @@ fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solv BoolOp::Union if b_in_a > ALL => Some(Some(a.clone())), BoolOp::Union if a_in_b > ALL => Some(Some(b.clone())), // The tool wholly inside, clear of the outside: a void. - BoolOp::Cut if b_in_a > ALL && clear_inside(ma, mb, a.size().max(b.size()) * 1e-6) => void(a, b).ok(), + BoolOp::Cut if b_in_a > ALL => { + let candidate = void(a, b).ok().flatten()?; + candidate.separated_lumps().ok()?; + Some(Some(candidate)) + } _ => None, } } -/// Does every vertex of `inner` lie inside `outer` and farther than `gap` from its surface? -fn clear_inside(outer: &solvecraft_geom::Mesh, inner: &solvecraft_geom::Mesh, gap: f64) -> bool { - if outer.triangles.len() * inner.positions.len() > 40_000_000 { - return false; +#[cfg(test)] +mod shortcut_tests { + use super::*; + + #[test] + fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() { + let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap(); + let outer = crate::sphere(Vec3::ZERO, 10.0).unwrap(); + let mesh = outer.tessellate(0.03).unwrap(); + for distance in [19.999, 20.001] { + let other = crate::sphere(direction * distance, 10.0).unwrap(); + let other_mesh = other.tessellate(0.03).unwrap(); + assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh).is_none()); + assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh).is_none()); + } + let protruding = crate::sphere(direction * 9.001, 1.0).unwrap(); + let protruding_mesh = protruding.tessellate(0.03).unwrap(); + assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh).is_none()); } - let tris: Vec<[Vec3; 3]> = outer.triangles.iter().filter_map(|t| outer.tri(t)).collect(); - inner.positions.iter().all(|p| { - outer.contains(*p) - && tris.iter().all(|[a, b, c]| { - // Distance to the triangle's plane bounds the distance to the triangle from below. - let n = (*b - *a).cross(*c - *a); - let Some(nn) = n.normalized() else { return true }; - let h = (*p - *a).dot(nn).abs(); - h > gap || { - let q = *p - nn * (*p - *a).dot(nn); - let inside = [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0); - !inside && [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| p.dist_to_segment(*u, *v) > gap) - } - }) - }) } /// `a` with the inside of `b` (wholly within it) taken out: a second, inward-facing shell. diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 2c29606f..a6e1f280 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -10,7 +10,108 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; use truck_modeling as mt; use crate::body::{Body, from_p3}; -use crate::{BoolOp, Result}; +use crate::{BoolOp, KernelError, Result}; + +const MAX_PLANAR_SEGMENTS: usize = 8192; +const MAX_PLANAR_EVENTS: usize = 131_072; +const MAX_PLANAR_PAIRS: usize = 16_000_000; + +/// Boolean of planar regions bounded by straight lines and circular arcs. Boundaries stay +/// analytic; disjoint results and retained islands are returned as separate regions. +pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, tolerance: f64) -> Result> { + crate::guard("planar region boolean", || { + if !tolerance.is_finite() || !(1e-9..=1.0).contains(&tolerance) { + return Err(KernelError::Invalid("planar boolean tolerance must be finite and between 1e-9 and 1 mm".into())); + } + let loops = || a.iter().chain(b).flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)); + let mut edges = 0usize; + let mut samples = 0usize; + for lp in loops() { + edges = edges.saturating_add(lp.segs.len()); + if edges > MAX_PLANAR_SEGMENTS || lp.segs.is_empty() || !lp.is_closed(tolerance * 10.0) { + return Err(KernelError::Invalid("planar boolean needs closed loops within its geometry budget".into())); + } + for s in &lp.segs { + let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9; + let valid = match *s { + Seg2::Line { a, b } => finite(a) && finite(b) && a.dist(b) > 1e-12, + Seg2::Arc { center, radius, start, sweep } => { + finite(center) + && radius.is_finite() + && radius > 0.0 + && radius < 1e9 + && start.is_finite() + && sweep.is_finite() + && sweep.abs() > 1e-12 + && sweep.abs() <= std::f64::consts::TAU + 1e-9 + } + _ => false, + }; + if !valid { + return Err(KernelError::Invalid("planar boolean supports finite nondegenerate lines and circular arcs".into())); + } + samples = samples.saturating_add(s.polyline(1e-3).len()); + if samples > 65_536 { + return Err(KernelError::Invalid("planar boolean sampled geometry exceeds its budget".into())); + } + } + } + boolean2d(a, b, op, tolerance) + .ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into())) + }) +} + +/// Extrude normalized planar material as one logical compound, retaining every disjoint +/// region and its holes. Planar arrangement certifies separation before shells are joined. +pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result { + crate::guard("planar material extrusion", || { + // Validate aggregate inputs first; an arrangement compares opposite operands, + // so regions in the same operand must be normalized through balanced unions. + planar_regions_boolean(regions, &[], BoolOp::Union, 1e-7)?; + let mut groups: Vec> = regions.iter().cloned().map(|r| vec![r]).collect(); + let mut remaining_pairs = 32_000_000usize; + while groups.len() > 1 { + let mut next = Vec::new(); + let mut iter = groups.into_iter(); + while let Some(a) = iter.next() { + if let Some(b) = iter.next() { + let count = |rs: &[Region2]| { + rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, l| n.saturating_add(l.segs.len())) + }; + remaining_pairs = remaining_pairs + .checked_sub(count(&a).saturating_mul(count(&b))) + .ok_or_else(|| KernelError::Invalid("planar extrusion arrangement exceeds its work budget".into()))?; + next.push(planar_regions_boolean(&a, &b, BoolOp::Union, 1e-7)?); + } else { + next.push(a); + } + } + groups = next; + } + let normalized = groups.pop().unwrap_or_default(); + if normalized.is_empty() { + return Err(KernelError::Invalid("empty planar material extrusion".into())); + } + for (i, a) in normalized.iter().enumerate() { + for b in normalized.iter().skip(i + 1) { + for s in std::iter::once(&a.outer).chain(&a.holes).flat_map(|l| &l.segs) { + for t in std::iter::once(&b.outer).chain(&b.holes).flat_map(|l| &l.segs) { + remaining_pairs = remaining_pairs + .checked_sub(1) + .ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?; + let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7) + || [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7); + if endpoints_touch || !crossings(s, t, 1e-7).is_empty() { + return Err(KernelError::Invalid("planar material pieces touch at a boundary".into())); + } + } + } + } + } + let pieces = crate::extrude(plane, &normalized, start, end)?; + crate::ops::join_pieces(pieces)?.ok_or_else(|| KernelError::Failed("empty planar material extrusion".into())) + }) +} /// A prism: the plane of its bottom cap (normal along the walls), its height and section. struct Prism { @@ -191,9 +292,83 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> { out } -/// Exact point-in-loop by winding (arcs as fine polylines are enough away from the boundary). +/// Ray parity against analytic lines/arcs. Fragment classification must not depend on a +/// display chord tolerance: a tiny tool can sit between a circular boundary and its chords. +fn inside_loop(lp: &Loop2, p: Vec2) -> bool { + let mut odd = false; + for seg in &lp.segs { + match *seg { + Seg2::Line { a, b } => { + if (a.y > p.y) != (b.y > p.y) { + let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y); + if x > p.x { + odd = !odd; + } + } + } + Seg2::Arc { center, radius, start, sweep } => { + let y = (p.y - center.y) / radius; + if !(-1.0..=1.0).contains(&y) { + continue; + } + let a = y.asin(); + let root = (1.0 - y * y).max(0.0).sqrt(); + let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-12; + for (i, (angle, x)) in [(a, center.x + radius * root), (std::f64::consts::PI - a, center.x - radius * root)].into_iter().enumerate() { + // A tangent has one geometric root. Interior tangencies contribute zero + // parity; a minimum at an endpoint follows the same half-open rule as lines. + if root == 0.0 && i == 1 { + continue; + } + if x <= p.x { + continue; + } + let mut delta = if sweep > 0.0 { angle - start } else { start - angle }; + delta = delta.rem_euclid(std::f64::consts::TAU); + if (delta - std::f64::consts::TAU).abs() < 1e-12 { + delta = 0.0; + } + if delta > sweep.abs() + 1e-12 { + continue; + } + let dy = angle.cos() * sweep; + let tangent = dy.abs() < sweep.abs() * 1e-12; + let at_start = delta < 1e-12; + let at_end = (delta - sweep.abs()).abs() < 1e-12; + let crosses = if full { + !tangent + } else if at_start { + dy > 0.0 && !tangent || tangent && y < 0.0 + } else if at_end { + dy < 0.0 && !tangent || tangent && y < 0.0 + } else { + !tangent + }; + if crosses { + odd = !odd; + } + } + } + _ => { + // Private prism callers only supply lines/arcs. Keep a controlled fallback + // for future internal callers; the public helper rejects other segment kinds. + let points = seg.polyline(1e-7); + for pair in points.windows(2) { + if let [a, b] = pair + && (a.y > p.y) != (b.y > p.y) + && a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) > p.x + { + odd = !odd; + } + } + } + } + } + odd +} + fn inside(regions: &[Region2], p: Vec2) -> bool { - regions.iter().any(|r| r.contains(p)) + regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p))) } fn dist_to(regions: &[Region2], p: Vec2) -> f64 { @@ -233,19 +408,30 @@ fn oriented(regions: &[Region2]) -> Vec { /// 2D boolean of regions bounded by lines and arcs. fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option> { + let count = + |rs: &[Region2]| rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, lp| n.saturating_add(lp.segs.len())); + let (na, nb) = (count(a), count(b)); + if na.saturating_add(nb) > MAX_PLANAR_SEGMENTS || na.saturating_mul(nb) > MAX_PLANAR_PAIRS { + return None; + } let (a, b) = (oriented(a), oriented(b)); let segs = |rs: &[Region2]| -> Vec { rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).flat_map(|l| l.segs.clone()).collect() }; let (sa, sb) = (segs(&a), segs(&b)); - if sa.len() * sb.len() > 4_000_000 || sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) { + if sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) { return None; } // Split every segment where it crosses the other side's boundary. let mut cut_a: Vec> = vec![Vec::new(); sa.len()]; let mut cut_b: Vec> = vec![Vec::new(); sb.len()]; + let mut events = 0usize; for (i, s) in sa.iter().enumerate() { for (j, t) in sb.iter().enumerate() { for (u, v) in crossings(s, t, tol) { + events += 2; + if events > MAX_PLANAR_EVENTS { + return None; + } cut_a.get_mut(i)?.push(u); cut_b.get_mut(j)?.push(v); } @@ -257,6 +443,10 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option MAX_PLANAR_EVENTS { + return None; + } cut_a.get_mut(i)?.push(u); } } @@ -267,6 +457,10 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option MAX_PLANAR_EVENTS { + return None; + } cut_b.get_mut(j)?.push(v); } } @@ -295,6 +489,9 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option = Vec::new(); // A's pieces. for (i, s) in sa.iter().enumerate() { + if cut_a.get(i)?.len() > MAX_PLANAR_SEGMENTS { + return None; + } for p in pieces(s, cut_a.get_mut(i)?) { let m = p.point_at(0.5); let on = dist_to(&b, m) < tol; @@ -312,18 +509,27 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option= MAX_PLANAR_SEGMENTS { + return None; + } kept.push(p); } } } // B's pieces (coincident ones were decided with A's). for (j, t) in sb.iter().enumerate() { + if cut_b.get(j)?.len() > MAX_PLANAR_SEGMENTS { + return None; + } for p in pieces(t, cut_b.get_mut(j)?) { let m = p.point_at(0.5); if dist_to(&a, m) < tol { continue; } let ins = inside(&a, m); + if kept.len() >= MAX_PLANAR_SEGMENTS { + return None; + } match op { BoolOp::Union if !ins => kept.push(p), BoolOp::Intersect if ins => kept.push(p), @@ -351,7 +557,9 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option 1 { + let full_circle = + segs.first().is_some_and(|s| matches!(s, Seg2::Arc { sweep, .. } if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9)); + if end.dist(first) < tol * 10.0 && (segs.len() > 1 || full_circle) { break; } let next = (0..kept.len()) @@ -379,7 +587,7 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option = outers.into_iter().map(|o| Region2 { outer: o, holes: Vec::new() }).collect(); for h in holes { let p = h.segs.first()?.point_at(0.5); - let r = regions.iter_mut().find(|r| r.outer.contains(p) && r.outer.signed_area().abs() > h.signed_area().abs())?; + let r = regions.iter_mut().find(|r| inside_loop(&r.outer, p) && r.outer.signed_area().abs() > h.signed_area().abs())?; r.holes.push(h); } Some(regions) @@ -707,3 +915,89 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { crate::guard("prism boolean", || Ok(simple_prism_boolean(a, b, op).or_else(|| stack_boolean(a, b, op)))).ok().flatten() } + +#[cfg(test)] +mod planar_tests { + use super::*; + fn rectangle(a: Vec2, b: Vec2) -> Region2 { + Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] } + } + #[test] + fn compound_planar_extrusion_normalizes_overlap_and_retains_islands() { + let plane = Plane::named("XY").unwrap(); + let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); + let b = rectangle(Vec2::new(5.0, 0.0), Vec2::new(15.0, 10.0)); + let body = extrude_planar_regions(&plane, &[a.clone(), b], 0.0, 2.0).unwrap(); + assert!((crate::measure(&body).unwrap().volume - 300.0).abs() < 1e-6); + let inner = Region2 { outer: Loop2::circle(Vec2::new(5.0, 5.0), 2.0), holes: vec![] }; + let body = extrude_planar_regions(&plane, &[a.clone(), inner.clone()], 0.0, 2.0).unwrap(); + assert!((crate::measure(&body).unwrap().volume - 200.0).abs() < 1e-6); + let annulus = Region2 { outer: a.outer, holes: vec![Loop2::circle(Vec2::new(5.0, 5.0), 3.0)] }; + let body = extrude_planar_regions(&plane, &[annulus, inner], 0.0, 2.0).unwrap(); + let expected = (100.0 - std::f64::consts::PI * 5.0) * 2.0; + assert!((crate::measure(&body).unwrap().volume - expected).abs() < 0.1); + assert_eq!(body.solid.boundaries().len(), 2); + let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); + let b = rectangle(Vec2::new(10.0, 10.0), Vec2::new(20.0, 20.0)); + assert!(extrude_planar_regions(&plane, &[a, b], 0.0, 2.0).is_err()); + let circles = [Vec2::ZERO, Vec2::new(10.0, 0.0)].map(|center| Region2 { outer: Loop2::circle(center, 5.0), holes: vec![] }); + assert!(extrude_planar_regions(&plane, &circles, 0.0, 2.0).is_err()); + } + + #[test] + fn analytic_circle_classification_respects_signed_sweeps_and_seams() { + for seam in [0.0, 0.13, std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2] { + for reversed in [false, true] { + let lp = Loop2::circle_from(Vec2::ZERO, 10.0, seam); + let lp = if reversed { lp.reversed() } else { lp }; + for p in [Vec2::ZERO, Vec2::new(9.9998, 0.0), Vec2::new(0.0, 9.9998), Vec2::new(0.0, -9.9998)] { + assert!(inside_loop(&lp, p), "{seam} {reversed} {p:?}"); + } + for p in [Vec2::new(11.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(0.0, -10.0)] { + assert!(!inside_loop(&lp, p), "{seam} {reversed} {p:?}"); + } + } + } + let half = Loop2 { + segs: vec![ + Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, -10.0) }, + ], + }; + for lp in [half.clone(), half.reversed()] { + assert!(inside_loop(&lp, Vec2::new(5.0, 0.0))); + assert!(!inside_loop(&lp, Vec2::new(-5.0, 0.0))); + assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0))); + } + } + #[test] + fn tiny_tool_between_circle_and_display_chord_is_not_lost() { + let circle = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] }; + let sampled = circle.outer.polyline(1e-3); + let direction = (sampled[0] + sampled[1]).normalized().unwrap(); + let at = direction * 9.9998; + let half = Vec2::new(0.000025, 0.000025); + let square = rectangle(at - half, at + half); + assert!(square.outer.segs.iter().all(|s| s.start().len() < 10.0)); + assert!(!circle.contains(at), "fixture must lie outside the display chord"); + let cut = planar_regions_boolean(std::slice::from_ref(&circle), std::slice::from_ref(&square), BoolOp::Cut, 1e-7).unwrap(); + assert_eq!(cut.len(), 1); + assert_eq!(cut[0].holes.len(), 1); + assert!((cut[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13); + let intersect = planar_regions_boolean(&[circle], &[square], BoolOp::Intersect, 1e-7).unwrap(); + assert_eq!(intersect.len(), 1); + assert!((intersect[0].area() - 2.5e-9).abs() < 1e-13); + } + #[test] + fn planar_region_api_rejects_hostile_values_and_budgets() { + let plate = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); + for tolerance in [f64::NAN, f64::INFINITY, 0.0, -1.0] { + assert!(planar_regions_boolean(std::slice::from_ref(&plate), &[], BoolOp::Union, tolerance).is_err()); + } + let mut bad = plate.clone(); + bad.outer.segs[0] = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }; + assert!(planar_regions_boolean(&[bad], &[], BoolOp::Union, 1e-7).is_err()); + let many = vec![plate; MAX_PLANAR_SEGMENTS / 4 + 1]; + assert!(planar_regions_boolean(&many, &[], BoolOp::Union, 1e-7).is_err()); + } +} diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs index cfcfe181..25c7a938 100644 --- a/crates/kernel/src/sew.rs +++ b/crates/kernel/src/sew.rs @@ -193,6 +193,72 @@ fn faces_connected(faces: &[mt::Face]) -> bool { seen.len() == faces.len() } +/// Keep disconnected closed boundaries as distinct shells, preserving orientation. +fn closed_components(faces: Vec) -> Result> { + const MAX_EDGE_USES: usize = 500_000; + const MAX_COMPONENTS: usize = 1024; + let fail = |s: &str| KernelError::Invalid(format!("sew: {s}")); + let mut by_edge: HashMap> = HashMap::new(); + let mut edge_uses = 0usize; + for (i, face) in faces.iter().enumerate() { + for edge in face.edge_iter() { + edge_uses = edge_uses.saturating_add(1); + if edge_uses > MAX_EDGE_USES { + return Err(fail("edge budget exceeded")); + } + let uses = by_edge.entry(edge.id()).or_default(); + if uses.len() >= 2 { + return Err(fail("nonmanifold edge contact")); + } + uses.push(i); + } + } + if by_edge.values().any(|uses| uses.len() != 2) { + return Err(fail("the faces do not close")); + } + let mut seen = vec![false; faces.len()]; + let mut vertices = HashMap::new(); + let mut components = Vec::new(); + for first in 0..faces.len() { + if seen.get(first).copied().unwrap_or(true) { + continue; + } + if components.len() >= MAX_COMPONENTS { + return Err(fail("component budget exceeded")); + } + let component = components.len(); + let mut todo = vec![first]; + if let Some(slot) = seen.get_mut(first) { + *slot = true; + } + let mut indices = Vec::new(); + while let Some(i) = todo.pop() { + let face = faces.get(i).ok_or_else(|| fail("missing component face"))?; + indices.push(i); + for vertex in face.vertex_iter() { + if vertices.insert(vertex.id(), component).is_some_and(|previous| previous != component) { + return Err(fail("bodies touch only at a vertex")); + } + } + for edge in face.edge_iter() { + let adjacent = by_edge.get(&edge.id()).ok_or_else(|| fail("missing component edge"))?; + for j in adjacent { + let slot = seen.get_mut(*j).ok_or_else(|| fail("missing adjacent face"))?; + if !*slot { + *slot = true; + todo.push(*j); + } + } + } + } + indices.sort_unstable(); + let component_faces = + indices.into_iter().map(|i| faces.get(i).cloned().ok_or_else(|| fail("missing component face"))).collect::>>()?; + components.push(component_faces.into()); + } + Ok(components) +} + /// A contact's edge sheets can terminate in separate vertex fans. Give each fan /// its own vertex at the same coordinates so the boundary remains a manifold. fn split_vertex_fans(faces: Vec) -> Result> { @@ -285,8 +351,13 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result { if faces.len() < 4 || faces.len() > 200_000 { return Err(KernelError::Invalid("a solid needs 4…200000 faces".into())); } + let mut edge_uses = 0usize; for f in faces { for l in &f.loops { + edge_uses = edge_uses.saturating_add(l.len().max(1)); + if edge_uses > 500_000 { + return Err(KernelError::Invalid("sew: edge budget exceeded".into())); + } for e in l { let (a, b) = e.ends(); if !a.is_finite() || !b.is_finite() || a.dist(b) < 1e-9 { @@ -331,8 +402,8 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result { if !sheets.is_empty() { out = split_vertex_fans(out)?; } - let shell: mt::Shell = out.into(); - let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?; + let shells = closed_components(out)?; + let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?; Body::new(solid) }) } @@ -363,6 +434,65 @@ mod tests { } } + fn box_faces(origin: Vec3, size: Vec3) -> Vec { + let p = |x: f64, y: f64, z: f64| origin + Vec3::new(x * size.x, y * size.y, z * size.z); + let (a, b, c, d) = (p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.)); + let (e, f, g, h) = (p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.)); + vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)] + } + + #[test] + fn disconnected_closed_faces_keep_every_material_component() { + let mut faces = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)); + faces.extend(box_faces(Vec3::X * 10.0, Vec3::new(2.0, 3.0, 4.0))); + for reverse in [false, true] { + if reverse { + faces.reverse(); + } + let body = sew(&faces).unwrap(); + assert_eq!(body.shell_count(), 2); + assert!(body.validity().is_empty()); + assert!((crate::measure(&body).unwrap().volume - 48.0).abs() < 1e-9); + let lumps = body.lumps().unwrap(); + assert_eq!(lumps.len(), 2); + assert!(lumps.iter().all(|b| (crate::measure(b).unwrap().volume - 24.0).abs() < 1e-9)); + let mesh = body.tessellate(0.01).unwrap(); + assert!(mesh.contains(Vec3::new(1.0, 1.0, 1.0))); + assert!(mesh.contains(Vec3::new(11.0, 1.0, 1.0))); + assert!(!mesh.contains(Vec3::new(5.0, 1.0, 1.0))); + let imported = crate::step_import(&crate::step_export(&[&body], "sewn pieces").unwrap()).unwrap(); + assert!(imported.warnings.is_empty()); + let volume: f64 = imported.bodies.iter().map(|b| crate::measure(&b.body).unwrap().volume).sum(); + assert!((volume - 48.0).abs() < 1e-6); + } + } + + #[test] + fn sewing_preserves_inward_cavity_orientation_and_refuses_vertex_contact() { + let outer = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)); + let mut inner = box_faces(Vec3::new(0.5, 0.75, 1.0), Vec3::new(1.0, 1.5, 2.0)); + for face in &mut inner { + for lp in &mut face.loops { + lp.reverse(); + for edge in lp { + if let EdgeSpec::Line { a, b } = edge { + std::mem::swap(a, b); + } + } + } + } + let cavity = sew(&outer.iter().chain(&inner).cloned().collect::>()).unwrap(); + assert_eq!(cavity.shell_count(), 2); + assert_eq!(cavity.lumps().unwrap().len(), 1); + assert!((crate::measure(&cavity).unwrap().volume - 21.0).abs() < 1e-9); + assert!(!cavity.tessellate(0.01).unwrap().contains(Vec3::new(1.0, 1.5, 2.0))); + let touching = box_faces(Vec3::new(2.0, 3.0, 4.0), Vec3::new(2.0, 3.0, 4.0)); + assert!(sew(&outer.iter().chain(&touching).cloned().collect::>()).unwrap_err().to_string().contains("vertex")); + let mut hostile = outer.clone(); + hostile[0].loops = vec![Vec::new(); 500_001]; + assert!(sew(&hostile).unwrap_err().to_string().contains("budget")); + } + #[test] fn a_sewn_box() { let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z); diff --git a/crates/kernel/src/shell_groups.rs b/crates/kernel/src/shell_groups.rs new file mode 100644 index 00000000..a76ea0a4 --- /dev/null +++ b/crates/kernel/src/shell_groups.rs @@ -0,0 +1,222 @@ +//! Group disconnected material regions without moving a cavity to another enclosing shell. +//! A valid shell hierarchy alternates material and void at every nesting level. + +use solvecraft_geom::{Aabb3, Mesh, Vec3}; + +use crate::{KernelError, Result}; + +pub(super) const MAX_SHELLS: usize = 1024; +pub(super) const MAX_POINTS: usize = 100_000; +pub(super) const MAX_TRIANGLES: usize = 100_000; +const MAX_PAIR_WORK: usize = 40_000_000; +const MAX_PROBES: usize = 2_000_000; +const MAX_CONTAINMENT_WORK: usize = 40_000_000; + +fn failed(detail: &str) -> KernelError { + KernelError::Failed(format!("lumps: {detail}")) +} + +fn box_of(points: &[Vec3]) -> Aabb3 { + let mut bounds = Aabb3::EMPTY; + for p in points { + bounds.add(*p); + } + bounds +} + +fn boxes_separate(a: Aabb3, b: Aabb3, gap: f64) -> bool { + a.max.x < b.min.x - gap + || b.max.x < a.min.x - gap + || a.max.y < b.min.y - gap + || b.max.y < a.min.y - gap + || a.max.z < b.min.z - gap + || b.max.z < a.min.z - gap +} + +fn unit(v: Vec3) -> Option { + let scale = v.x.abs().max(v.y.abs()).max(v.z.abs()); + if !scale.is_finite() || scale == 0.0 { + return None; + } + (v / scale).normalized() +} + +/// Separating axes include the in-plane edge normals for coplanar triangles. +fn triangles_meet(a: &[Vec3; 3], b: &[Vec3; 3], gap: f64) -> bool { + let [a0, a1, a2] = *a; + let [b0, b1, b2] = *b; + let ea = [a1 - a0, a2 - a1, a0 - a2]; + let eb = [b1 - b0, b2 - b1, b0 - b2]; + let na = (a1 - a0).cross(a2 - a0); + let nb = (b1 - b0).cross(b2 - b0); + let separated = |axis: Vec3| { + let Some(n) = unit(axis) else { return false }; + // Translate projections first so a far-away body does not lose its clearance. + let range = |points: &[Vec3; 3]| { + points.iter().map(|p| (*p - a0).dot(n)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x), hi.max(x))) + }; + let ((alo, ahi), (blo, bhi)) = (range(a), range(b)); + ahi < blo - gap || bhi < alo - gap + }; + if separated(na) || separated(nb) { + return false; + } + for e in ea { + if separated(na.cross(e)) { + return false; + } + for f in eb { + if separated(e.cross(f)) { + return false; + } + } + } + for e in eb { + if separated(nb.cross(e)) { + return false; + } + } + true +} + +pub(super) fn groups(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result>> { + if meshes.is_empty() || meshes.len() != volumes.len() || meshes.len() > MAX_SHELLS { + return Err(failed("shell count exceeds the grouping limit or is invalid")); + } + if !gap.is_finite() || gap <= 0.0 { + return Err(failed("invalid shell clearance")); + } + let mut points = 0usize; + let mut triangle_count = 0usize; + let mut triangles = Vec::with_capacity(meshes.len()); + let mut triangle_bounds = Vec::with_capacity(meshes.len()); + let mut bounds = Vec::with_capacity(meshes.len()); + for (mesh, volume) in meshes.iter().zip(volumes) { + points = points.checked_add(mesh.positions.len()).ok_or_else(|| failed("point count exceeds the grouping limit"))?; + triangle_count = triangle_count.checked_add(mesh.triangles.len()).ok_or_else(|| failed("triangle count exceeds the grouping limit"))?; + if points > MAX_POINTS || triangle_count > MAX_TRIANGLES { + return Err(failed("mesh exceeds the shell grouping limit")); + } + if !volume.is_finite() + || *volume == 0.0 + || mesh.positions.is_empty() + || mesh.triangles.is_empty() + || mesh.positions.iter().any(|p| !p.is_finite()) + { + return Err(failed("cannot determine a closed shell's orientation")); + } + let tris: Vec<_> = mesh.triangles.iter().map(|t| mesh.tri(t).ok_or_else(|| failed("invalid shell triangle"))).collect::>()?; + if tris.iter().any(|[a, b, c]| unit((*b - *a).cross(*c - *a)).is_none()) { + return Err(failed("degenerate shell triangle")); + } + triangle_bounds.push(tris.iter().map(|t| box_of(t)).collect::>()); + triangles.push(tris); + bounds.push(mesh.bounds()); + } + let mut inside = vec![vec![false; meshes.len()]; meshes.len()]; + let mut pair_work = 0usize; + let mut probes = 0usize; + let mut containment_work = 0usize; + for (i, mesh) in meshes.iter().enumerate() { + for j in i + 1..meshes.len() { + let (Some(a_bounds), Some(b_bounds)) = (bounds.get(i), bounds.get(j)) else { return Err(failed("missing shell bounds")) }; + if boxes_separate(*a_bounds, *b_bounds, gap) { + continue; + } + let (Some(a), Some(b)) = (triangles.get(i), triangles.get(j)) else { return Err(failed("missing shell triangles")) }; + let work = a.len().checked_mul(b.len()).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?; + pair_work = pair_work.checked_add(work).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?; + if pair_work > MAX_PAIR_WORK { + return Err(failed("shell boundary work exceeds the grouping limit")); + } + for (ai, ta) in a.iter().enumerate() { + for (bi, tb) in b.iter().enumerate() { + let (Some(ba), Some(bb)) = (triangle_bounds.get(i).and_then(|x| x.get(ai)), triangle_bounds.get(j).and_then(|x| x.get(bi))) + else { + return Err(failed("missing triangle bounds")); + }; + if !boxes_separate(*ba, *bb, gap) && triangles_meet(ta, tb, gap) { + return Err(failed("shell boundaries intersect or touch; cavity ownership is ambiguous")); + } + } + } + for (inner, outer) in [(i, j), (j, i)] { + let ib = bounds.get(inner).ok_or_else(|| failed("missing shell bounds"))?; + let ob = bounds.get(outer).ok_or_else(|| failed("missing shell bounds"))?; + // With boundary intersections already excluded, a connected shell + // extending beyond another's bounds cannot be wholly contained. + if ib.min.x < ob.min.x - gap + || ib.min.y < ob.min.y - gap + || ib.min.z < ob.min.z - gap + || ib.max.x > ob.max.x + gap + || ib.max.y > ob.max.y + gap + || ib.max.z > ob.max.z + gap + { + continue; + } + let inner_mesh = if inner == i { mesh } else { meshes.get(inner).ok_or_else(|| failed("missing shell"))? }; + probes = probes.checked_add(inner_mesh.positions.len()).ok_or_else(|| failed("shell probes exceed the grouping limit"))?; + if probes > MAX_PROBES { + return Err(failed("shell probes exceed the grouping limit")); + } + let outer_mesh = meshes.get(outer).ok_or_else(|| failed("missing shell"))?; + let probe_count = inner_mesh.positions.len(); + let work = probe_count + .checked_mul(outer_mesh.triangles.len()) + .and_then(|n| n.checked_mul(3)) + .ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?; + containment_work = containment_work.checked_add(work).ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?; + if containment_work > MAX_CONTAINMENT_WORK { + return Err(failed("shell containment work exceeds the grouping limit")); + } + let mut contained = None; + for p in inner_mesh.positions.iter().copied() { + let hit = outer_mesh.contains(p); + if contained.is_some_and(|previous| previous != hit) { + return Err(failed("shell containment is inconsistent; cavity ownership is ambiguous")); + } + contained = Some(hit); + } + if let Some(slot) = inside.get_mut(inner).and_then(|row| row.get_mut(outer)) { + *slot = contained == Some(true); + } + } + } + } + let mut parent = Vec::with_capacity(meshes.len()); + for i in 0..meshes.len() { + let row = inside.get(i).ok_or_else(|| failed("missing containment"))?; + let candidates: Vec<_> = row.iter().enumerate().filter_map(|(j, hit)| hit.then_some(j)).collect(); + for (k, a) in candidates.iter().enumerate() { + for b in candidates.iter().skip(k + 1) { + if !inside.get(*a).and_then(|r| r.get(*b)).copied().unwrap_or(false) + && !inside.get(*b).and_then(|r| r.get(*a)).copied().unwrap_or(false) + { + return Err(failed("multiple overlapping shell owners")); + } + } + } + let owner = candidates.into_iter().min_by(|a, b| { + volumes.get(*a).copied().unwrap_or(f64::INFINITY).abs().total_cmp(&volumes.get(*b).copied().unwrap_or(f64::INFINITY).abs()) + }); + let outward = volumes.get(i).is_some_and(|v| *v > 0.0); + match owner { + None if !outward => return Err(failed("inward shell has no enclosing material shell")), + Some(p) if volumes.get(p).is_some_and(|v| (*v > 0.0) == outward) => { + return Err(failed("nested shells do not alternate material and void")); + } + _ => {} + } + parent.push(owner); + } + let mut groups = Vec::new(); + for (i, v) in volumes.iter().enumerate() { + if *v <= 0.0 { + continue; + } + let mut group = vec![i]; + group.extend(parent.iter().enumerate().filter_map(|(j, p)| (*p == Some(i)).then_some(j))); + groups.push(group); + } + Ok(groups) +} diff --git a/crates/sketch/src/dims.rs b/crates/sketch/src/dims.rs index 164ca739..937e1314 100644 --- a/crates/sketch/src/dims.rs +++ b/crates/sketch/src/dims.rs @@ -59,6 +59,26 @@ pub fn dim_frame(sk: &Sketch, k: &ConstraintKind) -> Option { let (a, b) = line_ends(sk, l)?; DimFrame::Linear { p0: a, p1: b, dir: unit(b - a, Vec2::X) } } + LineAngle { l, value } => { + let (a, b) = line_ends(sk, l)?; + let d = unit(b - a, Vec2::X); + if value < 0.0 { + DimFrame::Angular { vertex: a, d0: d, sweep: (-value).rem_euclid(std::f64::consts::TAU) } + } else { + DimFrame::Angular { vertex: a, d0: Vec2::X, sweep: value.rem_euclid(std::f64::consts::TAU) } + } + } + ArcSweep { c, .. } => match sk.segs(c).first()? { + solvecraft_geom::Seg2::Arc { center, start, sweep, .. } => { + DimFrame::Angular { vertex: *center, d0: Vec2::from_angle(*start), sweep: *sweep } + } + _ => return None, + }, + SlotOverallLength { a, b, .. } => { + let (ca, cb) = (sk.center(a)?, sk.center(b)?); + let dir = unit(cb - ca, Vec2::X); + DimFrame::Linear { p0: ca - dir * sk.radius(a)?, p1: cb + dir * sk.radius(b)?, dir } + } PointLineDistance { p, l, .. } | LinearDiameter { p, l, .. } => { let (a, b) = line_ends(sk, l)?; let q = sk.point(p)?; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 2b5a0079..91981f70 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -233,6 +233,22 @@ pub enum ConstraintKind { l: usize, value: f64, }, + /// Signed angle of a line's stored start-to-end direction from sketch +X (radians). + LineAngle { + l: usize, + value: f64, + }, + /// Signed logical sweep of an arc (radians), preserving clockwise and major arcs. + ArcSweep { + c: usize, + value: f64, + }, + /// Overall length of a straight slot: cap-centre distance plus both cap radii. + SlotOverallLength { + a: usize, + b: usize, + value: f64, + }, Radius { c: usize, value: f64, @@ -282,6 +298,9 @@ impl ConstraintKind { | DistanceY { value, .. } | PointLineDistance { value, .. } | Length { value, .. } + | LineAngle { value, .. } + | ArcSweep { value, .. } + | SlotOverallLength { value, .. } | Radius { value, .. } | Diameter { value, .. } | ArcLength { value, .. } @@ -298,6 +317,9 @@ impl ConstraintKind { | DistanceY { value, .. } | PointLineDistance { value, .. } | Length { value, .. } + | LineAngle { value, .. } + | ArcSweep { value, .. } + | SlotOverallLength { value, .. } | Radius { value, .. } | Diameter { value, .. } | ArcLength { value, .. } @@ -307,7 +329,7 @@ impl ConstraintKind { } } pub fn is_angle(&self) -> bool { - matches!(self, ConstraintKind::Angle { .. }) + matches!(self, ConstraintKind::Angle { .. } | ConstraintKind::LineAngle { .. } | ConstraintKind::ArcSweep { .. }) } /// Short type name (Fusion-like) for listings. pub fn name(&self) -> &'static str { @@ -328,7 +350,8 @@ impl ConstraintKind { Distance { .. } | DistanceX { .. } | DistanceY { .. } | PointLineDistance { .. } | Length { .. } => "LinearDimension", Radius { .. } => "RadialDimension", Diameter { .. } => "DiameterDimension", - Angle { .. } => "AngularDimension", + Angle { .. } | LineAngle { .. } | ArcSweep { .. } => "AngularDimension", + SlotOverallLength { .. } => "LinearDimension", Smooth { .. } => "Smooth", ArcLength { .. } => "ArcLengthDimension", LinearDiameter { .. } => "LinearDiameterDimension", @@ -340,12 +363,12 @@ impl ConstraintKind { Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]), Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => (vec![p, q], vec![]), PointOnCurve { p, c } => (vec![p], vec![c]), - Horizontal { l } | Vertical { l } | Length { l, .. } => (vec![], vec![l]), - Radius { c, .. } | Diameter { c, .. } => (vec![], vec![c]), + Horizontal { l } | Vertical { l } | Length { l, .. } | LineAngle { l, .. } => (vec![], vec![l]), + Radius { c, .. } | Diameter { c, .. } | ArcSweep { c, .. } => (vec![], vec![c]), Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } => { (vec![], vec![a, b]) } - Angle { a, b, .. } => (vec![], vec![a, b]), + Angle { a, b, .. } | SlotOverallLength { a, b, .. } => (vec![], vec![a, b]), Midpoint { p, l } | PointLineDistance { p, l, .. } => (vec![p], vec![l]), Symmetric { p, q, l } => (vec![p, q], vec![l]), Fix { p } => (vec![p], vec![]), @@ -379,13 +402,13 @@ impl ConstraintKind { fp(p); fc(c); } - Horizontal { l } | Vertical { l } | Length { l, .. } => fc(l), - Radius { c, .. } | Diameter { c, .. } => fc(c), + Horizontal { l } | Vertical { l } | Length { l, .. } | LineAngle { l, .. } => fc(l), + Radius { c, .. } | Diameter { c, .. } | ArcSweep { c, .. } => fc(c), Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } => { fc(a); fc(b); } - Angle { a, b, .. } => { + Angle { a, b, .. } | SlotOverallLength { a, b, .. } => { fc(a); fc(b); } @@ -694,11 +717,12 @@ impl Sketch { Horizontal { l } | Vertical { l } | Length { l, .. } + | LineAngle { l, .. } | Midpoint { l, .. } | Symmetric { l, .. } | PointLineDistance { l, .. } | LinearDiameter { l, .. } => need_line(l)?, - ArcLength { c, .. } if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) => { + ArcLength { c, .. } | ArcSweep { c, .. } if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) => { return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into())); } Smooth { a, b } => { @@ -722,6 +746,36 @@ impl Sketch { need_round(a)?; need_round(b)?; } + SlotOverallLength { a, b, value } => { + if a == b || ![a, b].iter().all(|i| matches!(self.curves.get(*i).map(|c| &c.kind), Some(CurveKind::Arc { .. }))) { + return Err(SketchError::Invalid("overall slot length needs two different arc caps".into())); + } + let radius = |cap: usize| { + self.constraints + .iter() + .filter(|c| !c.driven) + .find_map(|dimension| match dimension.kind { + Radius { c, value } if c == cap => Some(value), + Diameter { c, value } if c == cap => Some(value / 2.0), + // Slot caps are constrained equal; a diameter on either drives both. + Diameter { c, value } | Radius { c, value } + if (c == a || c == b) + && self + .constraints + .iter() + .any(|e| matches!(e.kind, Equal { a: x, b: y } if (x == a && y == b) || (x == b && y == a))) => + { + Some(if matches!(dimension.kind, Diameter { .. }) { value / 2.0 } else { value }) + } + _ => None, + }) + .or_else(|| self.radius(cap)) + .unwrap_or(0.0) + }; + if value <= radius(a) + radius(b) + 1e-9 { + return Err(SketchError::Invalid("overall slot length must exceed its width".into())); + } + } Tangent { a, b } => { let free = |i: usize| self.curves.get(i).is_some_and(|c| c.kind.is_freeform()); if !(is_round(a) || is_round(b) || free(a) || free(b)) { @@ -739,6 +793,11 @@ impl Sketch { { return Err(SketchError::Invalid(format!("dimension value {v}"))); } + if let ArcSweep { value, .. } = *k + && !(value.abs() > 1e-9 && value.abs() < std::f64::consts::TAU - 1e-9) + { + return Err(SketchError::Invalid("arc sweep must be nonzero and less than a full turn".into())); + } Ok(()) } diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index b1779f56..1a480b70 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -249,6 +249,17 @@ impl State<'_> { _ => 0.0, } } + fn arc_sweep(&self, c: usize) -> f64 { + let Some(curve) = self.sk.curves.get(c) else { return 0.0 }; + let CurveKind::Arc { c: cp, a, b } = curve.kind else { return 0.0 }; + let sweep = ((self.p(b) - self.p(cp)).angle() - (self.p(a) - self.p(cp)).angle()).rem_euclid(std::f64::consts::TAU); + if curve.reversed { -sweep } else { sweep } + } + fn slot_length(&self, a: usize, b: usize) -> f64 { + let (ca, ra) = self.round(a); + let (cb, rb) = self.round(b); + ca.dist(cb) + ra + rb + } /// Curvature vector of a curve at its point `p` (towards the centre of curvature). fn curvature_at(&self, c: usize, p: usize) -> Vec2 { let Some(kind) = self.sk.curves.get(c).map(|c| &c.kind) else { return Vec2::ZERO }; @@ -433,6 +444,18 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { let (a, b) = s.line(l); out.push(a.dist(b) - value); } + LineAngle { l, value } => { + let (a, b) = s.line(l); + // A dimension with no typed length must not satisfy its angle by collapsing + // the line. Scale with the original geometry, independently of the unknowns. + let scale = match s.sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => s.sk.point(*a).zip(s.sk.point(*b)).map(|(a, b)| a.dist(b)).unwrap_or(1.0), + _ => 1.0, + }; + out.push(wrap_angle((b - a).angle() - value) * scale.max(1.0)); + } + ArcSweep { c, value } => out.push((s.arc_sweep(c) - value) * s.sk.radius(c).unwrap_or(1.0).max(1.0)), + SlotOverallLength { a, b, value } => out.push(s.slot_length(a, b) - value), Radius { c, value } => out.push(s.radius_of(c) - value), Diameter { c, value } => out.push(2.0 * s.round(c).1 - value), Angle { a, b, value, flip } => { @@ -483,6 +506,12 @@ fn measure(s: &State, k: &ConstraintKind) -> Option { let (a, b) = s.line(l); a.dist(b) } + LineAngle { l, value } => { + let (a, b) = s.line(l); + value + wrap_angle((b - a).angle() - value) + } + ArcSweep { c, .. } => s.arc_sweep(c), + SlotOverallLength { a, b, .. } => s.slot_length(a, b), Radius { c, .. } => s.radius_of(c), Diameter { c, .. } => 2.0 * s.round(c).1, Angle { a, b, flip, .. } => { @@ -614,14 +643,21 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { pt(&mut v, p); curve(&mut v, c); } - Horizontal { l } | Vertical { l } | Length { l, .. } | Radius { c: l, .. } | Diameter { c: l, .. } => curve(&mut v, l), + Horizontal { l } + | Vertical { l } + | Length { l, .. } + | LineAngle { l, .. } + | ArcSweep { c: l, .. } + | Radius { c: l, .. } + | Diameter { c: l, .. } => curve(&mut v, l), Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } - | Angle { a, b, .. } => { + | Angle { a, b, .. } + | SlotOverallLength { a, b, .. } => { curve(&mut v, a); curve(&mut v, b); } @@ -846,6 +882,20 @@ pub fn solve(sk: &mut Sketch) -> SolveReport { /// constraint between two entities holds the first so only the second moves. Everything else /// moves as little as it can. On failure the geometry is restored. pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { + let mut orientation_before = Vec::new(); + for constraint in &sk.constraints { + if !constraint.driven + && let ConstraintKind::ArcSweep { c, value } = constraint.kind + && value.is_finite() + && let Some(curve) = sk.curves.get_mut(c) + && let CurveKind::Arc { a, b, .. } = &mut curve.kind + && curve.reversed != (value < 0.0) + { + orientation_before.push((c, *a, *b, curve.reversed)); + std::mem::swap(a, b); + curve.reversed = value < 0.0; + } + } let lay = layout(sk, hold); let mut x = vec![0.0; lay.n]; for (i, p) in sk.points.iter().enumerate() { @@ -866,6 +916,34 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { } } let x0 = x.clone(); + // Exactly opposite directions sit on the wrapped angle's derivative discontinuity. + // Nudge one free endpoint into a continuous branch before Newton iterations. The + // untouched x0 still restores the complete original geometry on a failed solve. + for constraint in &sk.constraints { + if constraint.driven { + continue; + } + let ConstraintKind::LineAngle { l, value } = constraint.kind else { continue }; + let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { continue }; + let (pa, pb) = { + let state = State { sk, lay: &lay, x: &x }; + (state.p(*a), state.p(*b)) + }; + let delta = wrap_angle((pb - pa).angle() - value); + if delta.abs() < std::f64::consts::PI - 1e-5 || !delta.is_finite() { + continue; + } + let d = Vec2::from_angle((pb - pa).angle() - delta.signum() * 0.01) * pa.dist(pb); + let (point, at) = if lay.pvar.get(*b).copied().flatten().is_some() { (*b, pa + d) } else { (*a, pb - d) }; + if let Some(index) = lay.pvar.get(point).copied().flatten() { + if let Some(v) = x.get_mut(index) { + *v = at.x; + } + if let Some(v) = x.get_mut(index + 1) { + *v = at.y; + } + } + } let mut blocks: Vec<(Block, Vec)> = Vec::new(); for c in &sk.curves { @@ -1002,6 +1080,17 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { *r = use_x.get(v).copied().unwrap_or(*r).abs().max(1e-9); } } + if status == SolveStatus::Failed { + for (ci, a_before, b_before, reversed) in orientation_before.into_iter().rev() { + if let Some(curve) = sk.curves.get_mut(ci) + && let CurveKind::Arc { a, b, .. } = &mut curve.kind + { + *a = a_before; + *b = b_before; + curve.reversed = reversed; + } + } + } let vdet = |v: Option| v.is_none_or(|v| det.get(v).copied().unwrap_or(false)); let pdet = |p: usize| match lay.pvar.get(p).copied().flatten() { Some(v) => vdet(Some(v)) && vdet(Some(v + 1)), @@ -1031,3 +1120,38 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport { curve_determined, } } + +#[cfg(test)] +mod typed_dimension_tests { + use super::*; + + #[test] + fn failed_signed_sweep_restores_arc_orientation_and_coordinates() { + let mut sk = Sketch::new(); + let c = sk.add_arc(Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 10.0), [Some(0), None, None], None).unwrap(); + let id = sk.add_constraint(ConstraintKind::ArcSweep { c, value: std::f64::consts::FRAC_PI_2 }, None).unwrap(); + sk.curves[c].fixed = true; + assert!(solve(&mut sk).ok()); + let before = sk.curves.clone(); + let points = sk.points.clone(); + sk.constraints.iter_mut().find(|constraint| constraint.id == id).unwrap().kind.set_value(-std::f64::consts::FRAC_PI_2); + assert!(!solve(&mut sk).ok()); + assert_eq!(sk.curves, before); + assert_eq!(sk.points, points); + } + + #[test] + fn signed_dimensions_validate_kinds_values_and_survive_curve_removal() { + let mut sk = Sketch::new(); + let l = sk.add_line(Vec2::ZERO, Vec2::new(10.0, 10.0), Some(0), None, None).unwrap(); + assert!(sk.add_constraint(ConstraintKind::ArcSweep { c: l, value: 1.0 }, None).is_err()); + assert!(sk.add_constraint(ConstraintKind::LineAngle { l, value: f64::NAN }, None).is_err()); + sk.add_constraint(ConstraintKind::LineAngle { l, value: -std::f64::consts::FRAC_PI_4 }, None).unwrap(); + assert!(solve(&mut sk).ok()); + let CurveKind::Line { a, b } = sk.curves[l].kind else { panic!("line") }; + let direction = sk.points[b].pos - sk.points[a].pos; + assert!((direction.angle() + std::f64::consts::FRAC_PI_4).abs() < 1e-7); + sk.remove_curves(&[l]); + assert!(sk.constraints.is_empty()); + } +} diff --git a/crates/ui-egui/src/access.rs b/crates/ui-egui/src/access.rs index 7fc001e4..69b3d2c8 100644 --- a/crates/ui-egui/src/access.rs +++ b/crates/ui-egui/src/access.rs @@ -27,7 +27,7 @@ pub fn map(app: &SolveApp) -> Option { return Some(m); } let st = app.session.world_state(); - let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect(); + let meshes: Vec<_> = st.bodies.iter().filter(|b| app.session.body_visible(&b.name)).map(|b| b.mesh()).collect(); // Every new map gets a new version (the GPU uploads it once). let version = VERSION.with(|v| { v.set(v.get() + 1); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 5fbc0c8f..e4ecaf35 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -6,6 +6,10 @@ //! viewport, a double-click on a sketch edits it, and a right-click opens the item's menu //! ([`crate::context_menu`]). +#[cfg(test)] +#[path = "occurrence_ui_tests.rs"] +mod occurrence_ui_tests; + use std::collections::BTreeSet; use egui::{Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; @@ -21,6 +25,7 @@ thread_local! { /// The rows drawn this frame: (label, row, fold arrow area, open). For automation /// (`ui.at {browser | chevron}`, `ui.browser`). static DRAWN: std::cell::RefCell, Option)>> = const { std::cell::RefCell::new(Vec::new()) }; + static OCCURRENCES: std::cell::RefCell, String, Rect, bool)>> = const { std::cell::RefCell::new(Vec::new()) }; } /// The rows on screen: (label, row rect, fold arrow area, open). @@ -28,6 +33,10 @@ pub fn drawn_rows() -> Vec<(String, Rect, Option, Option)> { DRAWN.with(|d| d.borrow().clone()) } +pub fn drawn_occurrences() -> Vec<(Vec, String, Rect, bool)> { + OCCURRENCES.with(|d| d.borrow().clone()) +} + /// Browser state kept between frames. #[derive(Default)] pub struct TreeState { @@ -59,6 +68,11 @@ pub struct TreeState { #[derive(Clone, Debug)] pub struct OccMove { pub occurrence: u64, + /// Exact assembly placement used for world-space movement and the pivot. + pub path: Vec, + /// Effective local placement at dialog opening, including its pre-existing pending move. + pub baseline: Option, + pub had_pending: bool, /// Distances along X, Y, Z (mm) and turns about X, Y, Z (degrees) from where it was. pub translate: [f64; 3], pub angles: [f64; 3], @@ -70,7 +84,7 @@ pub struct OccMove { impl OccMove { pub fn new(occurrence: u64) -> Self { - OccMove { occurrence, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None } + OccMove { occurrence, path: Vec::new(), baseline: None, had_pending: false, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None } } } @@ -188,7 +202,7 @@ pub fn selection_group(app: &SolveApp, target: &Target) -> Option { Target::Body { name } => { let st = app.session.world_state(); let b = st.body(name)?; - (app.session.doc.body_component(name, b.feature), "bodies") + (b.component, "bodies") } Target::Sketch { id } => (app.session.doc.feature(*id)?.component, "sketches"), _ => return None, @@ -440,6 +454,13 @@ fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option } fn sel_of_key(k: &str) -> Option { + if let Some((_, id)) = k.rsplit_once("/s:") { + return id.parse().ok().map(|id| Sel::Feature { id }); + } + if let Some(p) = k.strip_prefix("o:") { + let path: Option> = p.split('/').map(|id| id.parse().ok()).collect(); + return path.filter(|p| !p.is_empty()).map(|path| Sel::Occurrence { path }); + } if let Some(b) = k.strip_prefix("b:") { return Some(Sel::Body { name: b.to_string() }); } @@ -475,6 +496,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); app.tree.order.clear(); DRAWN.with(|d| d.borrow_mut().clear()); + OCCURRENCES.with(|d| d.borrow_mut().clear()); let mut acts = Actions::default(); egui::Panel::left("sc_browser") .exact_size(250.0) @@ -598,9 +620,109 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, if id == 0 { crate::dialogs_assembly::browser_joints(app, ui, d); } - let children: Vec = app.session.doc.components.iter().filter(|c| c.parent == id).map(|c| c.id).collect(); - for c in children { - component_rows(app, ui, c, d, acts); + let children: Vec = app.session.doc.occurrences.iter().filter(|o| o.parent == id).map(|o| o.id).collect(); + for occurrence in children { + occurrence_rows(app, ui, vec![occurrence], d, acts); + } +} + +fn occurrence_key(path: &[u64]) -> String { + format!("o:{}", path.iter().map(u64::to_string).collect::>().join("/")) +} + +/// A placement, rather than a component definition: repeated child IDs remain distinct +/// because their complete paths are the selection and row identity. +fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec, depth: usize, acts: &mut Actions) { + let Ok(placement) = app.session.occurrence_placement(&path) else { return }; + let key = occurrence_key(&path); + let open = is_open(app, &key, true); + let active = app.session.active_occurrence_path.as_deref() == Some(path.as_slice()) + || (app.session.active_occurrence_path.is_none() + && app.session.active_component == placement.component + && app.session.doc.first_path(placement.component) == path); + let visible = app.session.occurrence_visible(&path); + let own_visible = !app.session.occurrence_hidden(&path); + let selected = app.session.selection.contains(&Sel::Occurrence { path: path.clone() }); + let faded = app.session.active_occurrence_path.as_ref().is_some_and(|p| !path.starts_with(p) && !p.starts_with(&path)); + let badges: &[&str] = if placement.grounded { &["pin"] } else { &[] }; + app.tree.order.push(key.clone()); + let r = draw_row( + ui, + ui.id().with(&key), + &Row { + depth, + fold: Some(open), + eye: Some(own_visible), + icon: "component", + label: &placement.name, + selected, + dim: faded || !visible, + radio: Some(active), + badges, + ..Default::default() + }, + ); + if let Some(rect) = r.rect.filter(|_| r.resp.is_some()) { + OCCURRENCES.with(|d| d.borrow_mut().push((path.clone(), placement.name.clone(), rect, open))); + crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(rect.right() - 12.0, rect.center().y)); + let x = rect.left() + 2.0 + depth as f32 * INDENT; + for (control, offset) in [("fold", 6.0), ("eye", 22.0), ("activate", 38.0)] { + crate::scenario::publish_handle(&format!("{control}:occurrence:{}", key.trim_start_matches("o:")), pos2(x + offset, rect.center().y)); + } + } + if let Some(resp) = &r.resp { + let definition = app.session.doc.components.iter().find(|c| c.id == placement.component).map(|c| c.name.as_str()).unwrap_or(""); + let location = path + .iter() + .filter_map(|id| app.session.doc.occurrences.iter().find(|o| o.id == *id)) + .map(|o| o.name.as_str()) + .collect::>() + .join(" / "); + let inherited = if own_visible && !visible { "\nHidden by a parent placement. Show the parent to reveal this instance." } else { "" }; + resp.clone().on_hover_text(format!( + "Component: {definition}\nPlacement: {}\nGeometry edits apply to every instance of this component.{inherited}", + location + )); + } + if r.fold { + toggle(app, &key, true); + } + if r.eye { + let _ = app.run("browser.visibility", json!({"visible": app.session.occurrence_hidden(&path), "paths": [path]})); + } + if r.radio || r.double { + let _ = app.run("component.activate", json!({"component": placement.component, "occurrence": placement.occurrence, "path": path})); + } + if r.clicked { + click_select(app, ui, key.clone(), Some(Sel::Occurrence { path: path.clone() })); + } + if r.hovered { + acts.hover_bodies = app.session.occurrence_bodies(&path, true).unwrap_or_default(); + } + if let Some(p) = r.secondary { + if !selected { + let _ = app.run("select.set", json!({"items": [Sel::Occurrence { path: path.clone() }]})); + } + acts.menu = Some((p, Target::Occurrence { path: path.clone() })); + } + if let Some(resp) = &r.resp + && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != placement.component && d.folder != "construction", DropMark::Box) + { + acts.drop = Some(DropAction::Component { to: placement.component, folder: d.folder, keys: d.keys.clone() }); + } + if !open { + return; + } + for folder in ["bodies", "sketches", "construction"] { + occurrence_folder_rows(app, ui, placement.component, &path, folder, depth + 1, acts); + } + let children: Vec = app.session.doc.occurrences.iter().filter(|o| o.parent == placement.component).map(|o| o.id).collect(); + if path.len() < 64 { + for occurrence in children { + let mut child = path.clone(); + child.push(occurrence); + occurrence_rows(app, ui, child, depth + 1, acts); + } } } @@ -1004,7 +1126,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { row_key: format!("b:{}", b.name), label: b.name.clone(), icon: "body", - visible: !app.session.visibility.hidden_bodies.contains(&b.name), + visible: app.session.body_visible(&b.name), selected: sel.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)), color: None, locked: app.ui.locked_bodies.contains(&b.name), @@ -1082,9 +1204,9 @@ fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) { for e in items { match folder { "bodies" => { - app.session.visibility.hidden_bodies.retain(|b| *b != e.key); + app.session.visibility.hidden_bodies.retain(|b| !e.bodies.contains(b)); if !on { - app.session.visibility.hidden_bodies.push(e.key.clone()); + app.session.visibility.hidden_bodies.extend(e.bodies.clone()); } } "sketches" => { @@ -1121,13 +1243,38 @@ fn folder_static(folder: &str) -> &'static str { /// A component's folder: its row, its groups and its items. Rows are drag sources; items, /// groups and the folder row are drop targets (reorder, into a group, out of groups). fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, depth: usize, acts: &mut Actions) { + folder_rows_at(app, ui, comp, None, folder, depth, acts); +} + +fn occurrence_folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: &[u64], folder: &str, depth: usize, acts: &mut Actions) { + folder_rows_at(app, ui, comp, Some(path), folder, depth, acts); +} + +fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option<&[u64]>, folder: &str, depth: usize, acts: &mut Actions) { let t = Tokens::get(); - let all = entries(app, comp, folder); + let mut all = entries(app, comp, folder); + let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}")); + if let Some(path) = path { + let Ok(placement) = app.session.occurrence_placement(path) else { return }; + for e in &mut all { + if folder == "bodies" { + let Some(body) = placement.bodies.iter().find(|b| b.source == e.key) else { continue }; + e.sel = Some(Sel::Body { name: body.world.clone() }); + e.target = Some(Target::Body { name: body.world.clone() }); + e.bodies = vec![body.world.clone()]; + e.visible = app.session.body_visible(&body.world); + e.selected = app.session.selection.contains(&Sel::Body { name: body.world.clone() }); + e.row_key = format!("b:{}", body.world); + } else { + e.row_key = format!("{prefix}/{}", e.row_key); + } + } + } if all.is_empty() { return; } let fstatic = folder_static(folder); - let key = format!("c{comp}/{folder}"); + let key = format!("{prefix}/{folder}"); if let Some(r) = app.tree.reveal.clone() && folder == "bodies" && all.iter().any(|e| e.key == r) @@ -1174,7 +1321,7 @@ fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, d let (groups, loose) = arrange(app, comp, folder, all); let loose_keys: Vec = loose.iter().map(|e| e.key.clone()).collect(); for (g, items) in &groups { - let gkey = format!("g{}", g.id); + let gkey = format!("{prefix}/g{}", g.id); let gopen = is_open(app, &gkey, true); let gvis = items.is_empty() || items.iter().any(|e| e.visible); let label = format!("{} ({})", g.name, items.len()); @@ -1203,12 +1350,12 @@ fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, d } if gopen { for e in items { - entry_row(app, ui, comp, fstatic, e, depth + 2, Some(g.id), &loose_keys, acts); + entry_row(app, ui, comp, path, fstatic, e, depth + 2, Some(g.id), &loose_keys, acts); } } } for e in &loose { - entry_row(app, ui, comp, fstatic, e, depth + 1, None, &loose_keys, acts); + entry_row(app, ui, comp, path, fstatic, e, depth + 1, None, &loose_keys, acts); } let _ = t; } @@ -1219,6 +1366,7 @@ fn entry_row( app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, + path: Option<&[u64]>, folder: &'static str, e: &Entry, depth: usize, @@ -1255,6 +1403,9 @@ fn entry_row( } if r.double && folder == "sketches" { if let Ok(id) = e.key.parse() { + if let Some(path) = path { + let _ = app.run("component.activate", json!({"component": comp,"occurrence":path.last(),"path":path})); + } app.edit_sketch(id); } } else if r.clicked { @@ -1367,9 +1518,29 @@ fn apply_drop(app: &mut SolveApp, d: DropAction) { /// Move/Copy of an occurrence: offsets and a rotation about Z, pending until Capture Position. fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) { let Some(mut m) = app.tree.occurrence_move.clone() else { return }; + if !m.path.is_empty() + && let Err(error) = app.session.occurrence_placement(&m.path) + { + app.tree.occurrence_move = None; + app.set_status(error.to_string(), true); + return; + } + if m.baseline.is_none() { + m.had_pending = app.session.pending_moves.contains_key(&m.occurrence); + m.baseline = app + .session + .pending_moves + .get(&m.occurrence) + .copied() + .or_else(|| app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.transform)); + } let ctx = ui.ctx().clone(); if m.pivot.is_none() { - m.pivot = occurrence_origin(app, m.occurrence); + m.pivot = if m.path.is_empty() { + occurrence_origin(app, m.occurrence) + } else { + app.session.occurrence_placement(&m.path).ok().map(|p| solvecraft_engine::doc::apply_point(&p.world, solvecraft_engine::geom::Vec3::ZERO)) + }; } let name = app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.name.clone()).unwrap_or_default(); let mut open = true; @@ -1383,28 +1554,33 @@ fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) { .resizable(false) .collapsible(false) .show(&ctx, |ui| { + if !m.path.is_empty() && app.session.occurrence_scope_count(&m.path).is_ok_and(|n| n > 1) { + ui.label("This child is shared. Position changes apply to every copy of its parent."); + } egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() { ui.label(*axis); if let Some(v) = m.translate.get_mut(k) { - ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm")); + let response = ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm")); + crate::scenario::publish_handle(&format!("occurrence-move:distance:{k}"), response.rect.center()); } ui.end_row(); } for (k, axis) in ["X angle", "Y angle", "Z angle"].iter().enumerate() { ui.label(*axis); if let Some(v) = m.angles.get_mut(k) { - ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg")); + let response = ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg")); + crate::scenario::publish_handle(&format!("occurrence-move:angle:{k}"), response.rect.center()); } ui.end_row(); } }); ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button("Capture Position").clicked() { + if ui.button("Capture All Positions").clicked() { action = Some("capture"); } - if ui.button("Revert").clicked() { + if ui.button("Revert All Positions").clicked() { action = Some("revert"); } }); @@ -1423,13 +1599,21 @@ fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) { app.tree.occurrence_move = None; } None if !open => { - let _ = app.run("component.revert_position", json!({})); + revert_occurrence_dialog(app, &m); app.tree.occurrence_move = None; } None => app.tree.occurrence_move = Some(m), } } +pub(crate) fn revert_occurrence_dialog(app: &mut SolveApp, m: &OccMove) { + if m.had_pending { + let _ = app.run("occurrence.move", json!({"occurrence":m.occurrence,"base_transform":m.baseline,"capture":false})); + } else { + let _ = app.run("component.revert_position", json!({"occurrences":[m.occurrence]})); + } +} + /// The pending move: turned about X, then Y, then Z through the starting origin, then moved. pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) { use crate::gizmo; @@ -1438,10 +1622,14 @@ pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) { let r = gizmo::mul(&gizmo::mul(&gizmo::rotation(Vec3::Z, a[2]), &gizmo::rotation(Vec3::Y, a[1])), &gizmo::rotation(Vec3::X, a[0])); let (axis, angle) = gizmo::axis_angle(&r); let o = m.pivot.unwrap_or(Vec3::ZERO); - let _ = app.session.execute("component.revert_position", &json!({})); + let baseline = m.baseline.or_else(|| app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.transform)); let _ = app.run( "occurrence.move", - json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }), + if m.path.is_empty() { + json!({ "occurrence": m.occurrence, "base_transform": baseline, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }) + } else { + json!({ "occurrence": m.occurrence, "path": m.path, "space": "world", "base_transform": baseline, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }) + }, ); } diff --git a/crates/ui-egui/src/configuration_rules_ui.rs b/crates/ui-egui/src/configuration_rules_ui.rs new file mode 100644 index 00000000..4e75ff74 --- /dev/null +++ b/crates/ui-egui/src/configuration_rules_ui.rs @@ -0,0 +1,356 @@ +//! Structured configuration rule editing and non-mutating row preview. +use crate::SolveApp; +use egui::{RichText, Ui}; +use serde_json::{Value, json}; +use solvecraft_engine::doc::config::{Column, ConfigRule, RuleCondition, RuleEffect, RuleOperator}; + +#[derive(Clone, Debug)] +pub struct Editor { + rule: ConfigRule, + row: String, + preview: Option, + + preview_revision: u64, + pub(super) drafts: std::collections::BTreeMap<(String, String), String>, + pub(super) table: Option<(u64, Value)>, +} +impl Default for Editor { + fn default() -> Self { + Self { + rule: ConfigRule { + id: String::new(), + name: "New rule".into(), + enabled: true, + length_unit: None, + condition: RuleCondition::Always, + effect: RuleEffect::DriveParameter { parameter: String::new(), expression: "10 mm".into() }, + }, + row: String::new(), + preview: None, + + preview_revision: 0, + drafts: Default::default(), + table: None, + } + } +} +fn choose(ui: &mut Ui, id: &str, value: &mut String, options: &[String]) { + egui::ComboBox::from_id_salt(id).selected_text(if value.is_empty() { "Choose…" } else { value.as_str() }).width(130.0).show_ui(ui, |ui| { + for option in options { + ui.selectable_value(value, option.clone(), option); + } + }); +} +fn expression(ui: &mut Ui, label: &str, value: &mut String) { + ui.label(label); + ui.add(egui::TextEdit::singleline(value).desired_width(160.0)); +} + +pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option, editor: &mut Editor) { + if editor.preview_revision != app.session.revision { + editor.preview = None; + } + let rules = app.session.doc.configs.rules.clone(); + let parameters: Vec = + app.session.doc.configs.columns.iter().filter_map(|c| if let Column::Param(n) = c { Some(n.clone()) } else { None }).collect(); + let all_parameters: Vec = app.session.doc.all_param_exprs().into_iter().map(|p| p.0).collect(); + let features: Vec<(u64, String)> = app.session.doc.features.iter().map(|f| (f.id, f.name.clone())).collect(); + let suppression_features: Vec<(u64, String)> = + features.iter().filter(|(id, _)| app.session.doc.configs.columns.contains(&Column::Suppress(*id))).cloned().collect(); + ui.separator(); + ui.label(RichText::new("Configuration rules").strong()); + ui.add(egui::Label::new("Rules run from top to bottom. Matched drives change effective values.").wrap()); + ui.add(egui::Label::new("Matched restrictions check the final values.").wrap()); + for (index, rule) in rules.iter().enumerate() { + ui.push_id(&rule.id, |ui| { + ui.horizontal(|ui| { + let mut enabled = rule.enabled; + if ui.checkbox(&mut enabled, "").on_hover_text("Enable rule").changed() { + let mut r = rule.clone(); + r.enabled = enabled; + *error = app.run("config.rule", json!({"rule":r})).err(); + } + ui.add(egui::Label::new(&rule.name).truncate()); + if ui.small_button("Edit").clicked() { + editor.rule = rule.clone(); + } + if ui.add_enabled(index > 0, egui::Button::new("↑").small()).on_hover_text("Move earlier").clicked() { + *error = app.run("config.rule", json!({"id":rule.id,"index":index-1})).err(); + } + if ui.add_enabled(index + 1 < rules.len(), egui::Button::new("↓").small()).on_hover_text("Move later").clicked() { + *error = app.run("config.rule", json!({"id":rule.id,"index":index+1})).err(); + } + if ui.small_button("Delete").clicked() { + *error = app.run("config.rule", json!({"id":rule.id,"delete":true})).err(); + } + }); + ui.label(match &rule.effect { + RuleEffect::DriveParameter { parameter, expression } => format!("Drives {parameter} = {expression}"), + RuleEffect::DriveSuppression { feature, value } => format!( + "Drives suppression {} = {value}", + features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("missing feature") + ), + RuleEffect::ParameterRange { parameter, minimum, maximum } => format!("Restricts {parameter}: {minimum} through {maximum}"), + RuleEffect::AllowParameterValues { parameter, expressions } => format!("Restricts {parameter}: {}", expressions.join(", ")), + }); + }); + } + ui.collapsing("Add or edit rule", |ui| { + ui.horizontal(|ui| { + expression(ui, "Rule name", &mut editor.rule.name); + ui.checkbox(&mut editor.rule.enabled, "Enabled"); + if ui.small_button("New").clicked() { + editor.rule = Editor::default().rule; + } + }); + let mut length_unit = editor.rule.length_unit.clone().unwrap_or_else(|| app.session.doc.units.clone()); + let old_unit = length_unit.clone(); + ui.horizontal(|ui| { + ui.label("Length units"); + choose(ui, "rule_length_units", &mut length_unit, &["mm".into(), "cm".into(), "m".into(), "in".into(), "ft".into()]); + }); + if length_unit != old_unit { + editor.rule.length_unit = Some(length_unit); + } + condition_editor(ui, &mut editor.rule.condition, &all_parameters, &features, 1); + let mut effect_kind = match editor.rule.effect { + RuleEffect::DriveParameter { .. } => "Drive parameter", + RuleEffect::DriveSuppression { .. } => "Drive suppression", + RuleEffect::ParameterRange { .. } => "Range", + RuleEffect::AllowParameterValues { .. } => "Allowed values", + } + .to_string(); + let prior = effect_kind.clone(); + ui.horizontal(|ui| { + ui.label("Then"); + choose( + ui, + "rule_effect_kind", + &mut effect_kind, + &["Drive parameter".into(), "Drive suppression".into(), "Range".into(), "Allowed values".into()], + ); + }); + if prior != effect_kind { + let parameter = parameters.first().cloned().unwrap_or_default(); + editor.rule.effect = match effect_kind.as_str() { + "Drive suppression" => RuleEffect::DriveSuppression { feature: suppression_features.first().map(|f| f.0).unwrap_or(0), value: true }, + "Range" => RuleEffect::ParameterRange { parameter, minimum: "0 mm".into(), maximum: "100 mm".into() }, + "Allowed values" => RuleEffect::AllowParameterValues { parameter, expressions: vec!["10 mm".into(), "20 mm".into()] }, + _ => RuleEffect::DriveParameter { parameter, expression: "10 mm".into() }, + }; + } + match &mut editor.rule.effect { + RuleEffect::DriveParameter { parameter, expression: value } => { + ui.horizontal(|ui| { + choose(ui, "effect_parameter", parameter, ¶meters); + expression(ui, "Value", value); + }); + } + RuleEffect::DriveSuppression { feature, value } => { + ui.horizontal(|ui| { + feature_choice(ui, "effect_feature", feature, &suppression_features); + ui.checkbox(value, "Suppressed"); + }); + } + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + choose(ui, "range_parameter", parameter, ¶meters); + ui.horizontal(|ui| { + expression(ui, "Minimum", minimum); + }); + ui.horizontal(|ui| { + expression(ui, "Maximum", maximum); + }); + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + choose(ui, "allowed_parameter", parameter, ¶meters); + let mut text = expressions.join("; "); + ui.label("Allowed expressions, separated by semicolons"); + if ui.add(egui::TextEdit::singleline(&mut text).desired_width(300.0)).changed() { + *expressions = text.split(';').map(|s| s.trim().to_string()).collect(); + } + } + } + if ui.add_enabled(!app.session.doc.configs.rows.is_empty(), egui::Button::new("Save Rule")).clicked() { + let result = (|| -> Result<(), String> { + let mut rule = editor.rule.clone(); + if rule.id.is_empty() { + rule.id = (1..).map(|n| format!("Rule{n}")).find(|id| !rules.iter().any(|r| r.id == *id)).unwrap_or_default(); + } + app.run("config.rule", json!({"rule":rule.clone()}))?; + editor.rule = app.session.doc.configs.rules.iter().find(|existing| existing.id == rule.id).cloned().unwrap_or(rule); + Ok(()) + })(); + *error = result.err(); + } + }); + let rows: Vec = app.session.doc.configs.rows.iter().map(|r| r.name.clone()).collect(); + if !rows.contains(&editor.row) { + editor.row = app.session.doc.configs.active.clone().or_else(|| rows.first().cloned()).unwrap_or_default(); + } + let previous_row = editor.row.clone(); + ui.horizontal(|ui| { + choose(ui, "preview_configuration", &mut editor.row, &rows); + if ui.add_enabled(!editor.row.is_empty(), egui::Button::new("Preview Row")).clicked() { + match app.session.execute("config.preview", &json!({"row":editor.row})) { + Ok(result) => { + editor.preview = Some(result); + editor.preview_revision = app.session.revision; + *error = None; + } + Err(e) => { + editor.preview = None; + *error = Some(e.to_string()); + } + } + } + }); + if previous_row != editor.row { + editor.preview = None; + } + if let Some(preview) = &editor.preview { + ui.collapsing(format!("Preview: {}", preview["active"].as_str().unwrap_or("row")), |ui| { + ui.label("Preview leaves the current design unchanged."); + if let Some(trace) = preview["trace"].as_array() { + for entry in trace { + ui.label(format!( + "{} — {}", + entry["name"].as_str().unwrap_or("rule"), + if entry["matched"].as_bool() == Some(true) { "matched" } else { "skipped" } + )); + if entry["matched"].as_bool() == Some(true) + && let Ok(effect) = serde_json::from_value::(entry["effect"].clone()) + { + ui.label(match effect { + RuleEffect::DriveParameter { parameter, expression } => format!("Driven: {parameter} = {expression}"), + RuleEffect::DriveSuppression { feature, value } => format!( + "Driven: {} is {}", + features.iter().find(|f| f.0 == feature).map(|f| f.1.as_str()).unwrap_or("feature"), + if value { "suppressed" } else { "unsuppressed" } + ), + RuleEffect::ParameterRange { parameter, minimum, maximum } => { + format!("Restricted: {parameter}, {minimum} through {maximum}") + } + RuleEffect::AllowParameterValues { parameter, expressions } => { + format!("Restricted: {parameter}, {}", expressions.join("; ")) + } + }); + } + } + } + if let Some(parameters) = preview["parameters"].as_array() { + for parameter in parameters { + if let Some(name) = parameter["name"].as_str() + && all_parameters.iter().any(|p| p == name) + { + ui.label(format!("{name} = {}", parameter["expression"].as_str().unwrap_or(""))); + } + } + } + }); + } +} +fn feature_choice(ui: &mut Ui, id: &str, feature: &mut u64, features: &[(u64, String)]) { + egui::ComboBox::from_id_salt(id) + .selected_text(features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("Choose feature…")) + .show_ui(ui, |ui| { + for (id, name) in features { + ui.selectable_value(feature, *id, name); + } + }); +} + +fn condition_editor(ui: &mut Ui, condition: &mut RuleCondition, parameters: &[String], features: &[(u64, String)], depth: usize) { + let mut kind = match condition { + RuleCondition::Always => "Always", + RuleCondition::Parameter { .. } => "Parameter", + RuleCondition::Suppression { .. } => "Suppression", + RuleCondition::All { .. } => "All", + RuleCondition::Any { .. } => "Any", + RuleCondition::Not { .. } => "Not", + } + .to_string(); + let previous = kind.clone(); + let mut choices = vec!["Always".into(), "Parameter".into(), "Suppression".into()]; + if depth < 8 { + choices.extend(["All".into(), "Any".into(), "Not".into()]); + } + ui.horizontal(|ui| { + ui.label("When"); + choose(ui, "condition_kind", &mut kind, &choices); + }); + if previous != kind { + *condition = match kind.as_str() { + "Parameter" => RuleCondition::Parameter { + parameter: parameters.first().cloned().unwrap_or_default(), + operator: RuleOperator::Ge, + expression: "10 mm".into(), + }, + "Suppression" => RuleCondition::Suppression { feature: features.first().map(|f| f.0).unwrap_or(0), value: true }, + "All" => RuleCondition::All { conditions: vec![RuleCondition::Always] }, + "Any" => RuleCondition::Any { conditions: vec![RuleCondition::Always] }, + "Not" => RuleCondition::Not { condition: Box::new(RuleCondition::Always) }, + _ => RuleCondition::Always, + }; + } + match condition { + RuleCondition::Parameter { parameter, operator, expression } => { + ui.horizontal(|ui| { + choose(ui, "condition_parameter", parameter, parameters); + egui::ComboBox::from_id_salt("condition_operator") + .width(45.0) + .selected_text(match operator { + RuleOperator::Eq => "=", + RuleOperator::Ne => "≠", + RuleOperator::Lt => "<", + RuleOperator::Le => "≤", + RuleOperator::Gt => ">", + RuleOperator::Ge => "≥", + }) + .show_ui(ui, |ui| { + for (label, op) in [ + ("=", RuleOperator::Eq), + ("≠", RuleOperator::Ne), + ("<", RuleOperator::Lt), + ("≤", RuleOperator::Le), + (">", RuleOperator::Gt), + ("≥", RuleOperator::Ge), + ] { + ui.selectable_value(operator, op, label); + } + }); + ui.add(egui::TextEdit::singleline(expression).desired_width(110.0)); + }); + } + RuleCondition::Suppression { feature, value } => { + ui.horizontal(|ui| { + feature_choice(ui, "condition_feature", feature, features); + ui.checkbox(value, "Suppressed"); + }); + } + RuleCondition::All { conditions } | RuleCondition::Any { conditions } => { + let mut remove = None; + for (index, child) in conditions.iter_mut().enumerate() { + ui.push_id(index, |ui| { + ui.indent("condition_group", |ui| { + condition_editor(ui, child, parameters, features, depth + 1); + if ui.small_button("Remove condition").clicked() { + remove = Some(index); + } + }); + }); + } + if let Some(index) = remove { + conditions.remove(index); + } + if ui.add_enabled(conditions.len() < 32, egui::Button::new("Add condition").small()).clicked() { + conditions.push(RuleCondition::Always); + } + } + RuleCondition::Not { condition } => { + ui.indent("negated_condition", |ui| { + condition_editor(ui, condition, parameters, features, depth + 1); + }); + } + RuleCondition::Always => {} + } +} diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index cc936c9f..5b12284b 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -34,6 +34,9 @@ pub enum Target { Component { id: u64, }, + Occurrence { + path: Vec, + }, /// A browser group of items. Group { id: u64, @@ -227,6 +230,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { Target::Viewport => touched_bodies(app), Target::Body { name } => vec![name.clone()], Target::Component { id } => component_bodies(app, *id), + Target::Occurrence { path } => app.session.occurrence_bodies(path, true).unwrap_or_default(), Target::Group { id } => crate::browser::group_bodies(app, *id), Target::Sketch { .. } | Target::Folder { .. } @@ -292,6 +296,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Body { name } => body_items(app, std::slice::from_ref(name), true), Target::Sketch { id } => sketch_items(app, *id), Target::Component { id } => component_items(app, *id), + Target::Occurrence { path } => occurrence_items(app, path), Target::Group { id } => crate::browser::group_items(app, *id), Target::Folder { component, folder } => crate::browser::folder_items(app, *component, folder), Target::Canvas { id } => crate::browser::canvas_items(app, *id), @@ -504,6 +509,54 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec { ] } +fn occurrence_items(app: &SolveApp, path: &[u64]) -> Vec { + let Ok(placement) = app.session.occurrence_placement(path) else { return Vec::new() }; + let mut entries = component_items(app, placement.component); + if app.session.occurrence_scope_count(path).is_ok_and(|n| n > 1) { + entries.insert(0, Item::heading("Shared child: edits affect all copies of its parent")); + } + let bodies = app.session.occurrence_bodies(path, true).unwrap_or_default(); + for item in &mut entries { + match item.id.as_str() { + "ui.activate" => { + item.params = Some(json!({"component": placement.component, "occurrence": placement.occurrence, "path": path})); + item.enabled = app.session.active_occurrence_path.as_deref() != Some(path); + } + "occurrence.ground" => { + item.label = if placement.grounded { "Unground" } else { "Ground" }.into(); + item.params = Some(json!({"occurrence": placement.occurrence, "grounded": !placement.grounded})); + } + "ui.moveOccurrence" => { + item.params = Some(json!({"occurrence": placement.occurrence, "path": path})); + item.enabled = !placement.grounded; + } + "ui.properties" => item.params = Some(json!({"occurrence": placement.occurrence, "path": path})), + "occurrence.copy" => item.params = Some(json!({"occurrence": placement.occurrence, "translate": [10,10,0]})), + "component.create" => item.params = Some(json!({"parent": placement.component, "parent_path": path})), + "component.paste_new" => { + item.label = "Paste New (Own Features)".into(); + item.params = Some(json!({"component":placement.component,"occurrence":placement.occurrence,"path":path,"translate":[10,10,0]})); + } + "ui.rename" => item.label = "Rename Component".into(), + "ui.appearance" => item.label = "Component Appearance".into(), + "ui.material" => item.label = "Component Physical Material".into(), + "ui.delete" => item.params = Some(json!({"occurrences": [placement.occurrence]})), + "ui.hide" | "ui.show" => { + item.id = "browser.visibility".into(); + item.params = Some(json!({"visible": app.session.occurrence_hidden(path), "paths": [path]})); + item.enabled = true; + } + "ui.isolate" => { + item.id = "ui.isolateOccurrence".into(); + item.params = Some(json!({"path": path})); + } + "ui.export" | "ui.saveMesh" => item.params = Some(json!({"bodies": bodies})), + _ => {} + } + } + entries +} + fn component_items(app: &SolveApp, id: u64) -> Vec { let doc = &app.session.doc; let occ = doc.occurrence_of(id); @@ -681,7 +734,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } "ui.show" => app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)), "ui.showAll" => { - app.session.visibility.hidden_bodies.clear(); + let _ = app.run("browser.visibility", json!({"folder":"bodies", "visible":true})); app.session.visibility.hidden_sketches.clear(); } "ui.isolate" => { @@ -722,8 +775,14 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { start_rename(app, what, at); } "ui.properties" => { - let command = if p.get("component").is_some() { "component.properties" } else { "inspect.measure" }; - let params = if command == "component.properties" { p.clone() } else { json!({ "bodies": bodies }) }; + let command = if p.get("occurrence").is_some() { + "occurrence.properties" + } else if p.get("component").is_some() { + "component.properties" + } else { + "inspect.measure" + }; + let params = if command == "inspect.measure" { json!({ "bodies": bodies }) } else { p.clone() }; match app.session.execute(command, ¶ms) { Ok(v) => { let title = v["name"] @@ -798,15 +857,27 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } } "ui.activate" => { - if let Some(id) = id_of(&p, "component") { - let _ = app.run("component.activate", json!({ "component": id })); + if id_of(&p, "component").is_some() { + let _ = app.run("component.activate", p); } } "ui.moveOccurrence" => { if let Some(o) = id_of(&p, "occurrence") { - app.tree.occurrence_move = Some(crate::browser::OccMove::new(o)); + let mut movement = crate::browser::OccMove::new(o); + movement.had_pending = app.session.pending_moves.contains_key(&o); + movement.path = p.get("path").and_then(Value::as_array).map(|p| p.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + movement.baseline = app + .session + .pending_moves + .get(&o) + .copied() + .or_else(|| app.session.doc.occurrences.iter().find(|occ| occ.id == o).map(|occ| occ.transform)); + app.tree.occurrence_move = Some(movement); } } + "ui.isolateOccurrence" => { + let _ = app.run("browser.visibility", json!({"visible": true, "paths": [p["path"]], "isolate": true})); + } "ui.canvasVisible" => drop(app.run("canvas.edit", p)), "ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p), "ui.workspace" => {} @@ -948,6 +1019,12 @@ pub fn delete(app: &mut SolveApp) { /// axes, components' bodies, canvases). When some are shown and some hidden, all are hidden. pub fn toggle_visibility(app: &mut SolveApp) { use solvecraft_engine::doc::FeatureKind; + let paths: Vec> = + app.session.selection.iter().filter_map(|s| if let Sel::Occurrence { path } = s { Some(path.clone()) } else { None }).collect(); + if !paths.is_empty() { + let visible = !paths.iter().any(|p| !app.session.occurrence_hidden(p)); + let _ = app.run("browser.visibility", json!({"visible": visible, "paths": paths})); + } let mut bodies: Vec = touched_bodies(app); for c in app.tree.picked_components.clone() { bodies.extend(component_bodies(app, c)); @@ -994,6 +1071,10 @@ pub fn rename_selection(app: &mut SolveApp, at: Pos2) -> bool { let what = match (app.session.selection.as_slice(), app.tree.picked_components.as_slice(), app.tree.picked_canvases.as_slice()) { ([Sel::Body { name }], [], []) => RenameWhat::Body(name.clone()), ([Sel::Feature { id }], [], []) => RenameWhat::Feature(*id), + ([Sel::Occurrence { path }], [], []) => match app.session.occurrence_placement(path) { + Ok(p) => RenameWhat::Component(p.component), + Err(_) => return false, + }, ([Sel::Plane { name }], [], []) => match app.session.doc.find_feature(name) { Some(f) => RenameWhat::Feature(f.id), None => return false, @@ -1424,14 +1505,9 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option { return None; } if item.is_heading() { - let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover()); - ui.painter().text( - pos2(r.left() + 8.0, r.center().y + 2.0), - Align2::LEFT_CENTER, - item.label.to_uppercase(), - FontId::proportional(10.5), - t.text_dim, - ); + let text = ui.painter().layout(item.label.to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0); + let (r, _) = ui.allocate_exact_size(vec2(w, (text.size().y + 8.0).max(20.0)), Sense::hover()); + ui.painter().galley(pos2(r.left() + 8.0, r.top() + 4.0), text, t.text_dim); return None; } let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), if item.enabled { Sense::click() } else { Sense::hover() }); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7e8e2cb0..7b68414a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -303,6 +303,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> ok(json!(list.into_iter().map(|(n, p)| json!({"name": n, "x": p.x, "y": p.y})).collect::>())) } "ui.at" => { + if let Some(path) = p.get("occurrence").and_then(Value::as_array) { + let ids: Option> = path.iter().map(Value::as_u64).collect(); + let Some(ids) = ids else { return err("occurrence path must contain integer ids") }; + let key = ids.iter().map(u64::to_string).collect::>().join("/"); + let control = s("control").unwrap_or("row"); + return match crate::scenario::handle_at(&format!("{control}:occurrence:{key}")) { + Some(q) => ok(json!([q.x, q.y])), + None => err("occurrence row or control is not shown"), + }; + } // A scenario point: {world: [x,y,z]} | {sketch: [x,y]} | {plane: "XY"} (the middle of an // origin plane's square) → screen point. if let Some(h) = p.get("handle").and_then(Value::as_str) { @@ -465,6 +475,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } } "ui.browser" => ok(json!({ + "occurrences": crate::browser::drawn_occurrences().iter().map(|(path, label, r, open)| json!({"path": path, "label": label, "y": r.center().y, "open": open})).collect::>(), "rows": crate::browser::drawn_rows().iter().map(|(label, r, _, open)| json!({"label": label, "y": r.center().y, "open": open})).collect::>(), "selection": app.session.selection, "picked_components": app.tree.picked_components, diff --git a/crates/ui-egui/src/delete.rs b/crates/ui-egui/src/delete.rs index cd4115ea..9089d952 100644 --- a/crates/ui-egui/src/delete.rs +++ b/crates/ui-egui/src/delete.rs @@ -28,6 +28,18 @@ fn is_empty(p: &Value) -> bool { ["items", "occurrences", "components", "canvases"].iter().all(|k| p.get(*k).and_then(Value::as_array).is_none_or(|a| a.is_empty())) } +fn shared_occurrences(app: &SolveApp, params: &Value) -> bool { + let explicit = params.get("occurrences").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64); + let selected = params + .get("items") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| item["type"] == "occurrence") + .filter_map(|item| item["path"].as_array()?.last()?.as_u64()); + explicit.chain(selected).any(|id| app.session.occurrence_id_scope_count(id).is_ok_and(|n| n > 1)) +} + /// Delete what is selected (the Delete key). A selected sketch dimension or constraint glyph /// goes first, as before. pub fn delete_selection(app: &mut SolveApp) { @@ -56,7 +68,8 @@ pub fn request(app: &mut SolveApp, params: Value) { params.get("items").and_then(Value::as_array).map_or(0, |a| a.iter().filter(|i| i["type"] == "feature" || i["type"] == "plane").count()); let extra = report["deleted"].as_array().map_or(0, Vec::len) > asked; let fails = report["would_fail"].as_array().is_some_and(|a| !a.is_empty()); - if extra || fails { + let shared = shared_occurrences(app, ¶ms); + if extra || fails || shared { app.menu.confirm = Some(Pending { params, report }); } else { run(app, ¶ms); @@ -101,6 +114,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -60.0)) .show(ctx, |ui| { ui.set_max_width(360.0); + if shared_occurrences(app, &p.params) { + ui.label("This child is shared. Deleting it removes it from every copy of its parent."); + } if !deleted.is_empty() { ui.label("These features will be deleted:"); for d in deleted.iter().take(20) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 520b081d..657047d1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -282,7 +282,7 @@ fn fits(a: Accept, s: &Sel) -> bool { Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0, Sel::Vertex { .. } => a & selection::VERTICES != 0, Sel::Feature { .. } => a & selection::FEATURES != 0, - Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } => false, + Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } | Sel::Occurrence { .. } => false, } } diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index b55b42e1..0fd328a5 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -119,7 +119,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { && !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. })); let state = app.session.world_state(); let existing = app.session.selection.iter().find_map(|sel| match sel { - Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.body_look(name, b.feature)), + Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.model_body_look(b)), Sel::Face { body, index, .. } => state.body(body).and_then(|b| { app.session .doc @@ -127,7 +127,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { .into_iter() .find(|(i, _)| i == index) .map(|(_, look)| look) - .or_else(|| app.session.doc.body_look(body, b.feature)) + .or_else(|| app.session.doc.model_body_look(b)) }), _ => None, }); diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index d0a3bb5d..c0c33034 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -16,6 +16,9 @@ use crate::dialogs::{Dialog, Kind, combo, field, row_label}; use crate::selection::{BODIES, SelInput}; use crate::theme::Tokens; +#[path = "configuration_rules_ui.rs"] +mod configuration_rules_ui; + /// One keyed value of a motion study: a joint's value (index) at steps. #[derive(Clone, Debug, PartialEq)] pub struct Track { @@ -60,6 +63,7 @@ pub enum Mo { Configurations { new_row: String, error: Option, + rule_editor: configuration_rules_ui::Editor, }, } @@ -114,7 +118,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { vec![], ), "explode.create" => (Mo::Explode { name: n("Exploded View", "explode.list"), scale: 1.0, shown: None, saved: None, error: None }, vec![]), - "config.table" | "config.configure" => (Mo::Configurations { new_row: String::new(), error: None }, vec![]), + "config.table" | "config.configure" => (Mo::Configurations { new_row: String::new(), error: None, rule_editor: Default::default() }, vec![]), _ => return None, }; Some((Kind::Motion(mo), inputs)) @@ -263,7 +267,11 @@ pub fn column_parts(c: &str) -> (&str, &str) { /// The value a table cell edit sends: suppress columns take booleans, parameters expressions. pub fn cell_value(column: &str, text: &str) -> Value { match column_parts(column).0 { - "suppress" => json!(matches!(text.trim().to_ascii_lowercase().as_str(), "suppressed" | "true" | "yes" | "1" | "off")), + "suppress" => match text.trim().to_ascii_lowercase().as_str() { + "suppressed" | "true" | "yes" | "1" | "off" => json!(true), + "unsuppressed" | "false" | "no" | "0" | "on" => json!(false), + _ => json!(text), + }, _ => json!(text), } } @@ -578,8 +586,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel ui.end_row(); } } - Mo::Configurations { new_row, error } => { - let v = app.session.execute("config.table", &json!({})).unwrap_or(Value::Null); + Mo::Configurations { new_row, error, rule_editor } => { + let revision = app.session.revision; + let v = rule_editor.table.as_ref().filter(|(r, _)| *r == revision).map(|(_, v)| v.clone()).unwrap_or_else(|| { + let value = app.session.execute("config.table", &json!({})).unwrap_or(Value::Null); + rule_editor.table = Some((revision, value.clone())); + value + }); let mut tb = table(&v); // Bare numbers show with their parameter's unit ("40" is 40 mm), as Fusion shows them. let kinds = app.session.doc.all_param_exprs(); @@ -588,7 +601,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel let (kind, n) = column_parts(c); if kind == "param" && val.trim().parse::().is_ok() - && let Some(unit) = kinds.iter().find(|x| x.0 == n).and_then(|x| unit_of(x.2)) + && let Some(unit) = app.session.doc.param(n).map(|parameter| parameter.unit.clone()).or_else(|| { + kinds.iter().find(|x| x.0 == n).and_then(|x| { + unit_of(x.2).map(|unit| { + if x.2 == solvecraft_engine::doc::expr::Kind::Length { app.session.doc.units.clone() } else { unit.to_string() } + }) + }) + }) { *val = format!("{} {unit}", val.trim()); } @@ -680,14 +699,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel run(app, error, "config.row", json!({"name": name, "values": { c: choice }})); } } - let id = egui::Id::new(("cfg", name, c)); - let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()); + let id = (name.clone(), c.clone()); + let mut text = rule_editor.drafts.get(&id).cloned().unwrap_or_else(|| shown.clone()); let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(if style { 106.0 } else { 164.0 })); if r.changed() { - ui.data_mut(|d| d.insert_temp(id, text.clone())); + rule_editor.drafts.insert(id.clone(), text.clone()); } if r.lost_focus() { - ui.data_mut(|d| d.remove::(id)); + rule_editor.drafts.remove(&id); if text != shown { run(app, error, "config.row", json!({"name": name, "values": { c: cell_value(c, &text) }})); } @@ -771,6 +790,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel } }); ui.end_row(); + configuration_rules_ui::show(ui, app, error, rule_editor); if let Some(e) = error { ui.label(""); ui.label(RichText::new(e.as_str()).color(t.error)); @@ -908,6 +928,82 @@ mod tests { assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm")); } + #[test] + fn configuration_compound_condition_and_rule_order_are_editable_in_dialog() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":1100})); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"parameters.change","params":{"name":"length","expression":"40 mm"}}}, + {"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}}, + {"start":"config.table"},{"widget":"Add or edit rule"}, + {"widget":"Always"},{"widget":"All"},{"widget":"Always"},{"widget":"Parameter"}, + {"widget":"Choose…"},{"widget":"length"},{"widget":"Save Rule"} + ])) + .unwrap(); + assert!( + matches!(&h.app.session.doc.configs.rules[0].condition,solvecraft_engine::doc::config::RuleCondition::All{conditions} if matches!(&conditions[0],solvecraft_engine::doc::config::RuleCondition::Parameter{..})) + ); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"config.rule","params":{"rule":{"id":"last","name":"Last drive","condition":{"type":"always"},"effect":{"type":"drive_parameter","parameter":"length","expression":"20 mm"}}}}}, + {"widget":"Preview Row"},{"widget":"Preview: Default"} + ])).unwrap(); + assert!(h.widget("length = 20 mm").is_some()); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm"); + h.run(&json!([{"widget":"↑"},{"widget":"Preview Row"}])).unwrap(); + assert_eq!(h.app.session.doc.configs.rules[0].id, "last"); + assert!(h.widget("length = 10 mm").is_some()); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm"); + } + + #[test] + fn configuration_rule_editor_creates_previews_edits_and_applies_driven_value() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":1000})); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"parameters.change","params":{"name":"length","expression":"40 mm"}}}, + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":"length","width":10,"height":10}}}, + {"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}}, + {"start":"config.table"}, + {"widget":"Add or edit rule"}, + {"widget":"Choose…"}, + {"widget":"length"}, + {"widget":"Save Rule"} + ])) + .unwrap(); + assert_eq!(h.app.session.doc.configs.rules.len(), 1); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm"); + let before = h.app.session.doc.clone(); + h.run(&json!([{"widget":"Preview Row"}])).unwrap(); + assert_eq!(h.app.session.doc, before); + h.run(&json!([{"widget":"Apply"}])).unwrap(); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "10 mm"); + assert!(h.app.session.doc.configuration_matches()); + h.run(&json!([ + {"widget":"10 mm","near":"Value"},{"key":"A","cmd":true},{"text":"20 mm"},{"widget":"Save Rule"}, + {"widget":"Apply"} + ])) + .unwrap(); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, "20 mm"); + assert_eq!(h.app.session.doc.configs.rows[0].cells[0], "40 mm"); + let before = h.app.session.doc.clone(); + h.run(&json!([ + {"widget":"Drive parameter"},{"widget":"Range"}, + {"widget":"0 mm","near":"Minimum"},{"key":"A","cmd":true},{"text":"100 mm"},{"widget":"Save Rule"} + ])) + .unwrap(); + assert_eq!(h.app.session.doc, before, "failed rule edit is atomic"); + assert!( + h.widget("configuration rules: rule `New rule` restricts the final value").is_some() + || h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .any(|(_, n)| n.label().or(n.value()).is_some_and(|text| text.contains("restricts the final value"))) + ); + } + #[test] fn configuration_style_picker_applies_then_inherits_material() { let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap(); diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 60a1ca1f..1ee625c9 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -81,6 +81,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { app.cam_anim = None; app.preview = crate::preview::PreviewState::default(); app.viewport.hover = None; + app.menu.props = None; let out_rev = app.session.revision; let out = DocSlot { session: std::mem::replace(&mut app.session, slot.session), @@ -140,6 +141,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) { let rev = app.session.revision; crate::dialogs::cancel(app); app.tool = None; + app.menu.props = None; app.session = session; app.session.invalidate_view(rev); app.cam = Camera::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34b7b49f..915e5f3b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -404,7 +404,13 @@ impl SolveApp { match &r { Ok(_) => { self.session.echo(format!("{} done", command_label(id))); - if matches!(id, "file.new" | "doc.open") { + if matches!(id, "file.new" | "doc.open" | "doc.recover") { + self.menu.props = None; + // The old document is already replaced: discard its editor without + // applying cancellation hooks (such as restoring its timeline marker). + self.dialog = None; + self.tool = None; + self.preview = preview::PreviewState::default(); self.cam = Camera::default(); self.fit_view(); } @@ -615,7 +621,7 @@ impl SolveApp { pub fn origin_visible(&self) -> bool { self.ui.show_origin || (self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch)) - && !self.session.world_state().bodies.iter().any(|b| !self.session.visibility.hidden_bodies.contains(&b.name))) + && !self.session.world_state().bodies.iter().any(|b| self.session.body_visible(&b.name))) } pub fn set_status(&mut self, s: impl Into, error: bool) { diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 64c4a7be..e74cd57c 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -13,6 +13,26 @@ fn sample_app() -> SolveApp { SolveApp::new(s, Services::default()) } +#[test] +fn properties_leave_with_the_document_but_survive_a_failed_open() { + let mut app = sample_app(); + app.menu.props = Some(("Prior body".into(), json!({"volume_mm3":42}))); + app.start("config.table"); + assert!(app.dialog.is_some()); + assert!(app.run("doc.open", json!({"path":"__missing_properties_regression__.solvecraft"})).is_err()); + assert!(app.menu.props.is_some()); + assert!(app.dialog.is_some()); + app.run("file.new", json!({"name":"New design"})).unwrap(); + assert!(app.menu.props.is_none()); + assert!(app.dialog.is_none()); + app.menu.props = Some(("New design".into(), json!({}))); + crate::documents::open_tab(&mut app, Session::default()).unwrap(); + assert!(app.menu.props.is_none()); + app.menu.props = Some(("Blank design".into(), json!({}))); + crate::documents::adopt(&mut app, Session::default()); + assert!(app.menu.props.is_none()); +} + fn find(items: &[Item], label: &str) -> Item { items .iter() diff --git a/crates/ui-egui/src/occurrence_ui_tests.rs b/crates/ui-egui/src/occurrence_ui_tests.rs new file mode 100644 index 00000000..9a5587ba --- /dev/null +++ b/crates/ui-egui/src/occurrence_ui_tests.rs @@ -0,0 +1,182 @@ +use crate::context_menu::{self, Target}; +use crate::scenario::Harness; +use serde_json::json; +use solvecraft_engine::Sel; + +fn fixture() -> (Harness, u64, u64, u64) { + let mut h = Harness::new(); + let part = h.app.run("component.create", json!({"name":"Block"})).unwrap(); + let component = part["component"].as_u64().unwrap(); + let first = part["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[50,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.frames(3); + (h, component, first, second) +} + +fn click(h: &mut Harness, path: &[u64], control: &str) { + let point = h.call("ui.at", json!({"occurrence":path,"control":control})); + assert_eq!(point["ok"], true, "{point}"); + let p = &point["result"]; + let result = h.call("ui.click", json!({"x":p[0],"y":p[1]})); + assert_eq!(result["ok"], true); +} + +#[test] +fn occurrence_second_row_selects_exact_path_and_menu_actions() { + let (mut h, component, first, second) = fixture(); + click(&mut h, &[second], "row"); + assert_eq!(h.app.session.selection, vec![Sel::Occurrence { path: vec![second] }]); + let bounds = crate::viewport::selection_bounds(&h.app).unwrap(); + assert!((bounds.center().x - 55.0).abs() < 1e-8); + let target = Target::Occurrence { path: vec![second] }; + let items = context_menu::items(&h.app, &target); + let independent = items.iter().find(|i| i.id == "component.paste_new").unwrap(); + assert_eq!(independent.label, "Paste New (Own Features)"); + assert_eq!(independent.params.as_ref().unwrap()["occurrence"], second); + assert_eq!(independent.params.as_ref().unwrap()["path"], json!([second])); + let properties = items.iter().find(|i| i.id == "ui.properties").unwrap(); + assert_eq!(properties.params.as_ref().unwrap()["occurrence"], second); + context_menu::run_item(&mut h.app, properties, egui::Pos2::ZERO); + assert!((h.app.menu.props.as_ref().unwrap().1["total"]["center_of_mass"][0].as_f64().unwrap() - 55.0).abs() < 1e-8); + let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap(); + context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO); + assert_eq!(h.app.tree.occurrence_move.as_ref().unwrap().path, vec![second]); + let ground = items.iter().find(|i| i.id == "occurrence.ground").unwrap(); + context_menu::run_item(&mut h.app, ground, egui::Pos2::ZERO); + assert!(h.app.session.doc.occurrences.iter().find(|o| o.id == second).unwrap().grounded); + assert!(!h.app.session.doc.occurrences.iter().find(|o| o.id == first).unwrap().grounded); + h.app.tree.occurrence_move = None; + click(&mut h, &[second], "activate"); + assert_eq!(h.app.session.active_component, component); + assert_eq!(h.app.session.active_occurrence_path, Some(vec![second])); +} + +#[test] +fn occurrence_eye_and_fold_are_independent_and_survive_rename() { + let (mut h, component, first, second) = fixture(); + click(&mut h, &[second], "eye"); + assert!(h.app.session.occurrence_hidden(&[second])); + assert!(h.app.session.occurrence_visible(&[first])); + let second_body = h.app.session.occurrence_bodies(&[second], true).unwrap()[0].clone(); + assert!(!h.app.session.body_visible(&second_body)); + click(&mut h, &[second], "fold"); + assert!(h.app.tree.collapsed.contains(&format!("o:{second}"))); + assert!(!h.app.tree.collapsed.contains(&format!("o:{first}"))); + h.app.run("component.rename", json!({"component":component,"name":"Renamed Block"})).unwrap(); + h.frames(3); + assert!(h.app.session.occurrence_hidden(&[second])); + click(&mut h, &[second], "eye"); + assert!(h.app.session.occurrence_visible(&[second])); +} + +#[test] +fn occurrence_move_dialog_keeps_other_pending_moves_and_reuses_initial_baseline() { + let (mut h, _, first, second) = fixture(); + h.app.run("occurrence.move", json!({"occurrence":first,"translate":[0,10,0],"capture":false})).unwrap(); + h.app.run("occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})).unwrap(); + let first_pending = h.app.session.pending_moves[&first]; + let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] }); + let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap(); + context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO); + let mut m = h.app.tree.occurrence_move.clone().unwrap(); + m.pivot = + Some(solvecraft_engine::doc::apply_point(&h.app.session.occurrence_placement(&[second]).unwrap().world, solvecraft_engine::geom::Vec3::ZERO)); + m.translate = [10.0, 0.0, 0.0]; + crate::browser::apply_occurrence_move(&mut h.app, &m); + let at = h.app.session.occurrence_placement(&[second]).unwrap(); + assert!((solvecraft_engine::doc::apply_point(&at.world, solvecraft_engine::geom::Vec3::ZERO).x - 65.0).abs() < 1e-8); + m.translate = [3.0, 0.0, 0.0]; + crate::browser::apply_occurrence_move(&mut h.app, &m); + let at = h.app.session.occurrence_placement(&[second]).unwrap(); + assert!((solvecraft_engine::doc::apply_point(&at.world, solvecraft_engine::geom::Vec3::ZERO).x - 58.0).abs() < 1e-8); + assert_eq!(h.app.session.pending_moves[&first], first_pending); + crate::browser::revert_occurrence_dialog(&mut h.app, &m); + let restored = h.app.session.occurrence_placement(&[second]).unwrap(); + assert!((solvecraft_engine::doc::apply_point(&restored.world, solvecraft_engine::geom::Vec3::ZERO).x - 55.0).abs() < 1e-8); + assert_eq!(h.app.session.pending_moves[&first], first_pending); + h.app.run("component.revert_position", json!({"occurrences":[second]})).unwrap(); + context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO); + let mut fresh = h.app.tree.occurrence_move.clone().unwrap(); + assert!(!fresh.had_pending); + fresh.translate = [2.0, 0.0, 0.0]; + crate::browser::apply_occurrence_move(&mut h.app, &fresh); + crate::browser::revert_occurrence_dialog(&mut h.app, &fresh); + assert!(!h.app.session.pending_moves.contains_key(&second)); + assert_eq!(h.app.session.pending_moves[&first], first_pending); +} + +#[test] +fn occurrence_delete_targets_second_copy_even_if_first_was_selected() { + let (mut h, _, first, second) = fixture(); + click(&mut h, &[first], "row"); + let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] }); + let delete = items.iter().find(|i| i.id == "ui.delete").unwrap(); + context_menu::run_item(&mut h.app, delete, egui::Pos2::ZERO); + crate::delete::confirm(&mut h.app, true); + assert!(h.app.session.doc.occurrences.iter().any(|o| o.id == first)); + assert!(!h.app.session.doc.occurrences.iter().any(|o| o.id == second)); + assert_eq!(h.app.session.world_state().bodies.len(), 1); +} + +#[test] +fn occurrence_new_component_keeps_inactive_second_parent_authoring_context() { + let (mut h, _, first, second) = fixture(); + assert_eq!(h.app.session.active_component, 0); + let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] }); + let create = items.iter().find(|i| i.id == "component.create").unwrap(); + assert_eq!(create.params.as_ref().unwrap()["parent_path"], json!([second])); + context_menu::run_item(&mut h.app, create, egui::Pos2::ZERO); + let active = h.app.session.active_occurrence_path.as_ref().unwrap(); + assert_eq!(active.len(), 2); + assert_eq!(active[0], second); + assert_ne!(active[0], first); + h.app.run("solid.box", json!({"corner":[51,1,1],"length":2,"width":2,"height":2})).unwrap(); + let feature = h.app.session.model.state().bodies.last().unwrap().feature; + let mut centers: Vec = + h.app.session.world_state().bodies.iter().filter(|b| b.feature == feature).map(|b| b.mesh().bounds().center().x).collect(); + centers.sort_by(f64::total_cmp); + assert_eq!(centers.len(), 2); + assert!((centers[0] - 2.0).abs() < 1e-8 && (centers[1] - 52.0).abs() < 1e-8, "{centers:?}"); +} + +#[test] +fn occurrence_body_appearance_editor_inherits_definition_precision() { + let (mut h, _, _, second) = fixture(); + h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#123456","appearance":"Tinted source","opacity":0.123456})).unwrap(); + let name = h.app.session.occurrence_bodies(&[second], false).unwrap()[0].clone(); + h.app.session.selection = vec![Sel::Body { name }]; + let (kind, _) = crate::dialogs_appearance::start(&h.app, "appearance.assign").unwrap(); + let crate::dialogs::Kind::Appearance(look) = kind else { panic!("appearance editor") }; + assert_eq!(look.color, [0x12, 0x34, 0x56]); + assert_eq!(look.opacity, 0.123456); + assert_eq!(look.look.as_deref(), Some("Tinted source")); +} + +#[test] +fn occurrence_nested_shared_child_has_distinct_rows_and_scope() { + let mut h = Harness::new(); + let parent = h.app.run("component.create", json!({"name":"Assembly"})).unwrap(); + let parent_component = parent["component"].as_u64().unwrap(); + let parent_occurrence = parent["occurrence"].as_u64().unwrap(); + let child = h.app.run("component.create", json!({"name":"Child"})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + let other = h.app.run("occurrence.copy", json!({"component":parent_component,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.frames(3); + click(&mut h, &[other, child], "row"); + assert_eq!(h.app.session.selection, vec![Sel::Occurrence { path: vec![other, child] }]); + assert!((crate::viewport::selection_bounds(&h.app).unwrap().center().x - 85.0).abs() < 1e-8); + let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![other, child] }); + assert!(items.iter().any(|i| i.label.starts_with("Shared child:"))); + click(&mut h, &[other, child], "fold"); + assert!(h.app.tree.collapsed.contains(&format!("o:{other}/{child}"))); + assert!(!h.app.tree.collapsed.contains(&format!("o:{parent_occurrence}/{child}"))); + click(&mut h, &[other, child], "eye"); + assert!(h.app.session.occurrence_visible(&[parent_occurrence, child])); + assert!(!h.app.session.occurrence_visible(&[other, child])); + let delete = items.iter().find(|i| i.id == "ui.delete").unwrap(); + context_menu::run_item(&mut h.app, delete, egui::Pos2::ZERO); + assert!(h.app.menu.confirm.is_some(), "shared child deletion must communicate scope before removing both renderings"); +} diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 77925592..38c3bf39 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -50,7 +50,7 @@ pub fn boxes_for(cmd: &str, n: usize) -> Vec { /// A locked box's value (mm or radians). fn locked(app: &SolveApp, t: &Tool, label: &str) -> Option { - let b = t.dims.iter().find(|b| b.label == label && b.locked)?; + let b = t.dims.iter().chain(&t.dim_history).find(|b| b.label == label && b.locked)?; app.session.doc.eval(&b.value, b.kind).ok().filter(|v| v.is_finite()) } @@ -181,29 +181,71 @@ fn fmt(kind: ValueKind, v: f64) -> String { /// Make the tool's boxes match its stage (new boxes when a point was placed). pub fn sync(t: &mut Tool) { if t.dims_stage != t.pts.len() { + if t.dims_stage == 1 + && t.pts.len() == 2 + && matches!(t.cmd, "sketch.arc.center_point" | "sketch.slot.center_to_center" | "sketch.slot.overall") + { + t.dim_history.extend(t.dims.iter().filter(|dimension| dimension.locked).cloned()); + } else if t.pts.len() <= t.dims_stage || t.cmd == "sketch.line" { + t.dim_history.clear(); + } t.dims = boxes_for(t.cmd, t.pts.len()); t.dims_stage = t.pts.len(); t.dims_focus = !t.dims.is_empty(); } } -/// The dimension constraints the locked values ask for after the shape made `curves`: -/// (entities, type, value). -pub fn constraints(t: &Tool, curves: &[String]) -> Vec<(Vec, &'static str, String)> { - let mut out = Vec::new(); - for b in t.dims.iter().filter(|b| b.locked) { - let target = match (t.cmd, b.label) { - ("sketch.line", "Length") | ("sketch.rectangle.two_point" | "sketch.rectangle.center", "Width") => curves.first().map(|c| (c, "length")), - ("sketch.rectangle.two_point" | "sketch.rectangle.center", "Height") => curves.get(1).map(|c| (c, "length")), - ("sketch.circle.center", "Diameter") => curves.first().map(|c| (c, "diameter")), - ("sketch.arc.center_point", "Radius") => curves.first().map(|c| (c, "radius")), - _ => None, - }; - if let Some((c, ty)) = target { - out.push((vec![c.clone()], ty, b.value.clone())); +/// Clear a shape's input immediately, including when another first point arrives before redraw. +pub fn reset(t: &mut Tool) { + t.dims.clear(); + t.dim_history.clear(); + t.dims_stage = usize::MAX; + t.dims_focus = false; +} + +pub fn has_locked(t: &Tool, label: &str) -> bool { + t.dims.iter().chain(&t.dim_history).any(|dimension| dimension.locked && dimension.label == label) +} + +/// Original expressions, rather than sampled numbers, become the shape's permanent dimensions. +pub fn dimensions(t: &Tool) -> Value { + let values = t + .dim_history + .iter() + .chain(&t.dims) + .filter(|dimension| dimension.locked && dimension.label != "Sides") + .map(|dimension| (dimension.label.to_ascii_lowercase(), json!(dimension.value))) + .collect::>(); + Value::Object(values) +} + +/// Invalid values stay in the current field, so users can correct them before another stage. +pub fn validate(app: &SolveApp, t: &Tool) -> Result<(), String> { + for dimension in t.dim_history.iter().chain(&t.dims).filter(|dimension| dimension.locked) { + if dimension.value.len() > 4096 { + return Err(format!("{} expression is too long", dimension.label)); + } + let value = app.session.doc.eval(&dimension.value, dimension.kind).map_err(|error| format!("{}: {error}", dimension.label))?; + if !value.is_finite() || value.abs() > 1e9 { + return Err(format!("{} must be finite and within the model's size limits", dimension.label)); + } + match dimension.label { + "Angle" => {} + "Sweep" if value.abs() > 1e-9 && value.abs() < std::f64::consts::TAU - 1e-9 => {} + "Sweep" => return Err("Sweep must lie strictly between -360 and 360 degrees, and cannot be zero".into()), + "Sides" if (3.0..=200.0).contains(&value) && value.fract() == 0.0 => {} + "Sides" => return Err("Sides must be an integer from 3 to 200".into()), + _ if value > 1e-9 => {} + _ => return Err(format!("{} must be positive", dimension.label)), } } - out + if t.cmd == "sketch.slot.overall" + && let (Some(length), Some(width)) = (locked(app, t, "Length"), locked(app, t, "Width")) + && length <= width + { + return Err("Overall slot Length must exceed Width".into()); + } + Ok(()) } /// A locked box value by label (for the polygon side count). @@ -285,7 +327,9 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option let fill = if b.locked { tk.accent_soft } else { tk.panel }; egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| { let r = ui - .add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).event_filter(egui::EventFilter { + // The drawing tool handles Enter. Keep the text edit focused when a + // rejected value needs correction, instead of surrendering the keyboard. + .add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).return_key(None).event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, @@ -326,11 +370,10 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option (enter && (any_focused || nothing)).then_some(e) } -/// JSON for a typed dimension. -pub fn dimension_params(entities: &[String], ty: &str, value: &str) -> Value { - json!({"entities": entities, "type": ty, "value": value}) -} - #[cfg(test)] #[path = "sketch_dims_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "sketch_typed_intent_tests.rs"] +mod typed_intent_tests; diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 240b7c1f..55be6973 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -373,6 +373,7 @@ pub fn tool_for(id: &str) -> Option { hover: None, picks: Vec::new(), dims: Vec::new(), + dim_history: Vec::new(), dims_stage: usize::MAX, dims_focus: false, }) diff --git a/crates/ui-egui/src/sketch_typed_intent_tests.rs b/crates/ui-egui/src/sketch_typed_intent_tests.rs new file mode 100644 index 00000000..646ed903 --- /dev/null +++ b/crates/ui-egui/src/sketch_typed_intent_tests.rs @@ -0,0 +1,184 @@ +//! Keyboard-authored intent must survive stage transitions and later model edits. +use crate::scenario::Harness; +use serde_json::json; +use solvecraft_engine::sketch::{ConstraintKind, CurveKind}; + +fn start(command: &str) -> Harness { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.step(&json!({"start":command})).unwrap(); + h.step(&json!({"click":{"world":[0,0,0]}})).unwrap(); + h.step(&json!({"move":{"world":[20,0,0]}})).unwrap(); + h +} +fn type_value(h: &mut Harness, expression: &str) { + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":expression})).unwrap(); +} +fn enter(h: &mut Harness) { + h.step(&json!({"key":"Enter"})).unwrap(); +} +fn constraints(h: &Harness) -> Vec { + h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.constraints.iter().map(|c| c.kind.clone()).collect() +} + +#[test] +fn typed_arc_radius_and_signed_sweep_survive_stages_parameter_edit_and_reopen() { + let mut h = start("sketch.arc.center_point"); + h.app.run("parameters.add", json!({"name":"arc_radius","expression":"5 mm"})).unwrap(); + type_value(&mut h, "arc_radius"); + enter(&mut h); + let tool = h.app.tool.as_ref().unwrap(); + assert_eq!(tool.pts.len(), 2); + assert_eq!(tool.dim_history[0].value, "arc_radius"); + assert_eq!(tool.dims[0].label, "Sweep"); + type_value(&mut h, "-90 deg"); + enter(&mut h); + assert!(h.app.tool.as_ref().unwrap().dim_history.is_empty()); + let kinds = constraints(&h); + assert!(kinds.iter().any(|k| matches!(k, ConstraintKind::Radius{value,..} if (*value-5.0).abs()<1e-7))); + assert!(kinds.iter().any(|k| matches!(k, ConstraintKind::ArcSweep{value,..} if (*value+std::f64::consts::FRAC_PI_2).abs()<1e-7))); + h.app.run("parameters.change", json!({"name":"arc_radius","expression":"8 mm"})).unwrap(); + let path = h.dir.join("typed-arc.solvecraft"); + h.app.run("file.save", json!({"path":path})).unwrap(); + h.app.run("doc.open", json!({"path":path})).unwrap(); + let state = h.app.session.world_state(); + let sketch = &state.sketches[0].sketch; + let curve = &sketch.curves[0]; + assert!(curve.reversed, "negative typed sweep must remain clockwise"); + let CurveKind::Arc { c, a, .. } = curve.kind else { panic!("expected arc") }; + assert!((sketch.points[c].pos.dist(sketch.points[a].pos) - 8.0).abs() < 1e-6); + assert!(sketch.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::ArcSweep{value,..} if value<0.0))); +} + +#[test] +fn slots_keep_typed_length_and_width_as_dimensions_and_start_fresh() { + for command in ["sketch.slot.center_to_center", "sketch.slot.overall"] { + let mut h = start(command); + type_value(&mut h, "30"); + enter(&mut h); + assert_eq!(h.app.tool.as_ref().unwrap().dim_history[0].value, "30"); + h.step(&json!({"move":{"world":[15,5,0]}})).unwrap(); + type_value(&mut h, "6"); + enter(&mut h); + let kinds = constraints(&h); + assert!(kinds.iter().any(|k| matches!(k,ConstraintKind::Diameter{value,..} if (*value-6.0).abs()<1e-7)), "{command}"); + if command.ends_with("overall") { + assert!(kinds.iter().any(|k| matches!(k,ConstraintKind::SlotOverallLength{value,..} if (*value-30.0).abs()<1e-7))); + } else { + assert!(kinds.iter().any(|k| matches!(k,ConstraintKind::Length{value,..} if (*value-30.0).abs()<1e-7))); + } + h.step(&json!({"click":{"world":[50,20,0]}})).unwrap(); + h.step(&json!({"move":{"world":[70,20,0]}})).unwrap(); + let tool = h.app.tool.as_ref().unwrap(); + assert!(tool.dim_history.is_empty()); + assert!(tool.dims.iter().all(|d| !d.locked)); + type_value(&mut h, "12"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.tool.as_ref().unwrap().dim_history.is_empty()); + assert!(h.app.tool.as_ref().unwrap().pts.is_empty()); + } +} + +#[test] +fn invalid_stage_values_remain_editable_without_partial_geometry() { + let mut h = start("sketch.arc.center_point"); + type_value(&mut h, "-3"); + enter(&mut h); + assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1); + assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "-3"); + assert!(constraints(&h).is_empty()); + type_value(&mut h, "5"); + enter(&mut h); + type_value(&mut h, "360 deg"); + enter(&mut h); + assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 2); + assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "360 deg"); + assert!(constraints(&h).is_empty()); + type_value(&mut h, "90 deg"); + enter(&mut h); + assert_eq!(constraints(&h).iter().filter(|c| c.is_dimension()).count(), 2); + + let mut slot = start("sketch.slot.overall"); + type_value(&mut slot, "4"); + enter(&mut slot); + type_value(&mut slot, "6"); + enter(&mut slot); + assert_eq!(slot.app.tool.as_ref().unwrap().pts.len(), 2); + assert_eq!(slot.app.tool.as_ref().unwrap().dim_history[0].value, "4"); + assert_eq!(slot.app.tool.as_ref().unwrap().dims[0].value, "6"); + assert!(constraints(&slot).is_empty()); + type_value(&mut slot, "2"); + enter(&mut slot); + assert_eq!(constraints(&slot).iter().filter(|c| c.is_dimension()).count(), 2); +} + +#[test] +fn polygon_radius_is_persistent_and_fractional_sides_are_rejected() { + let mut h = start("sketch.polygon.inscribed"); + type_value(&mut h, "10"); + h.step(&json!({"key":"Tab"})).unwrap(); + type_value(&mut h, "5.5"); + enter(&mut h); + assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1); + assert!(constraints(&h).is_empty()); + type_value(&mut h, "5"); + enter(&mut h); + assert!(constraints(&h).iter().any(|c| matches!(c,ConstraintKind::Radius{value,..} if (*value-10.0).abs()<1e-7))); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert_eq!(sketch.curves.iter().filter(|c| !c.construction).count(), 5); +} + +#[test] +fn line_angle_survives_drag_and_one_undo_removes_geometry_and_dimensions() { + let mut h = start("sketch.line"); + let undo = h.app.session.undo.len(); + type_value(&mut h, "10"); + h.step(&json!({"key":"Tab"})).unwrap(); + type_value(&mut h, "30 deg"); + enter(&mut h); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.tool.as_ref().unwrap().dims.iter().all(|d| !d.locked), "next chained segment starts without stale locks"); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"drag":[{"world":[8.660254037844386,5,0]},{"world":[16,14,0]}]})).unwrap(); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + let CurveKind::Line { a, b } = sketch.curves[0].kind else { panic!("expected line") }; + let vector = sketch.points[b].pos - sketch.points[a].pos; + assert!((vector.len() - 10.0).abs() < 1e-6); + assert!((vector.angle() - 30f64.to_radians()).abs() < 1e-6); + // A drag may itself add an undo entry. Undo that before the original atomic draw. + while h.app.session.undo.len() > undo + 1 { + h.app.run("edit.undo", json!({})).unwrap(); + } + h.app.run("edit.undo", json!({})).unwrap(); + assert!(constraints(&h).is_empty()); + assert!(h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.is_empty()); +} + +#[test] +fn typed_drawing_preserves_construction_and_centerline_modes_at_undo_limit() { + for mode in [1, 2] { + let mut h = start("sketch.line"); + crate::sketch_palette::set_mode(&mut h.app, mode); + let snapshot = h.app.session.undo.last().unwrap().clone(); + h.app.session.undo.resize(200, snapshot); + type_value(&mut h, "10"); + h.step(&json!({"key":"Tab"})).unwrap(); + type_value(&mut h, "30 deg"); + enter(&mut h); + assert_eq!(h.app.session.undo.len(), 200); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert_eq!(sketch.curves[0].construction, mode == 1); + assert_eq!(sketch.curves[0].centerline, mode == 2); + h.app.run("edit.undo", json!({})).unwrap(); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert!(sketch.curves.is_empty()); + assert!(sketch.constraints.is_empty()); + crate::sketch_palette::set_mode(&mut h.app, 0); + } +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 174311d8..64180911 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -31,6 +31,8 @@ pub struct Tool { /// Inline dimension boxes for the shape being drawn, the point count they were made for, /// and whether the first box should take the keyboard. pub dims: Vec, + /// Locked inputs from earlier stages of this shape (arc radius, slot length). + pub dim_history: Vec, pub dims_stage: usize, pub dims_focus: bool, } @@ -67,7 +69,17 @@ impl Tool { "sketch.constraint.symmetry" => ("sketch.constraint.symmetry", Kind::Pick(3)), _ => return crate::sketch_tools::tool_for(id), }; - Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new(), dims: Vec::new(), dims_stage: usize::MAX, dims_focus: false }) + Some(Tool { + cmd, + kind, + pts: Vec::new(), + hover: None, + picks: Vec::new(), + dims: Vec::new(), + dim_history: Vec::new(), + dims_stage: usize::MAX, + dims_focus: false, + }) } } @@ -79,7 +91,7 @@ pub fn hint(id: &str) -> String { "sketch.rectangle.three_point" => "3-point rectangle: click two points of one edge, then the width".into(), "sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(), "sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(), - "sketch.arc.center_point" => "Arc: click centre, start, then end (counter-clockwise)".into(), + "sketch.arc.center_point" => "Arc: click centre and start, then click end or type a signed sweep".into(), "sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(), "sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(), _ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(), @@ -175,40 +187,77 @@ pub fn place(app: &mut SolveApp, p: (Vec2, Option)) { app.tool = Some(tool); return; }; + if let Err(error) = crate::sketch_dims::validate(app, &tool) { + app.set_status(error, true); + app.tool = Some(tool); + return; + } tool.pts.push(p); if tool.cmd == "sketch.line" { if tool.pts.len() >= 2 { let (a, b) = (tool.pts[tool.pts.len() - 2].clone(), tool.pts[tool.pts.len() - 1].clone()); - if let Ok(v) = app.run("sketch.line", json!({"points": [arg(&a), arg(&b)], "infer": true})) { + let angled = crate::sketch_dims::has_locked(&tool, "Angle"); + let dimensioned = tool.dims.iter().any(|dimension| dimension.locked); + if let Ok(v) = draw(app, &tool, json!({"points": [arg(&a), arg(&b)], "infer": !angled})) { let curves: Vec = v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect(); - dimension(app, &tool, &curves); // Chain: the next segment starts at this one's end point. if let Some(id) = curves.first() { - crate::inference::commit(app, id, b.0); + if angled { + crate::inference::clear(); + } else { + // Endpoint inference is part of this drawing. Preserve its original + // undo history even when the history is already at the engine's cap. + let history = dimensioned.then(|| std::mem::take(&mut app.session.undo)); + crate::inference::commit(app, id, b.0); + if let Some(history) = history { + app.session.undo = history; + } + } let last = tool.pts.len() - 1; tool.pts[last].1 = Some(format!("{id}.end")); + // The chain needs only its current endpoint, even after many segments. + tool.pts = vec![tool.pts[last].clone()]; } + crate::sketch_dims::reset(&mut tool); + } else { + tool.pts.pop(); } } } else if n > 0 && tool.pts.len() >= n { - let curves = run_shape(app, &tool); - dimension(app, &tool, &curves); - tool.pts.clear(); + match run_shape(app, &tool) { + Ok(_) => { + tool.pts.clear(); + crate::sketch_dims::reset(&mut tool); + } + Err(error) => { + app.set_status(error, true); + tool.pts.pop(); + } + } } app.tool = Some(tool); } -/// Typed values become dimension constraints on the new curves. -fn dimension(app: &mut SolveApp, t: &Tool, curves: &[String]) { - for (ents, ty, value) in crate::sketch_dims::constraints(t, curves) { - let _ = app.run("sketch.dimension", crate::sketch_dims::dimension_params(&ents, ty, &value)); +/// Geometry and typed constraints are committed atomically, as one undo step. +fn draw(app: &mut SolveApp, t: &Tool, params: Value) -> Result { + let dimensions = crate::sketch_dims::dimensions(t); + if dimensions.as_object().is_none_or(|values| values.is_empty()) { + app.run(t.cmd, params) + } else { + let result = app.run("sketch.draw.dimensioned", json!({"command": t.cmd, "params": params, "dimensions": dimensions}))?; + // The internal wrapper has no toolbar category; apply the original creator's line + // type while retaining the draw's undo history, including at the history limit. + let history = std::mem::take(&mut app.session.undo); + crate::sketch_palette::apply_mode(app, t.cmd, &result); + app.session.undo = history; + Ok(result) } } -fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { +fn run_shape(app: &mut SolveApp, t: &Tool) -> Result, String> { let p = &t.pts; let d = |a: usize, b: usize| p[a].0.dist(p[b].0); - let sides = crate::sketch_dims::locked_value(app, t, "Sides").map_or(6, |v| v.round().clamp(3.0, 200.0) as usize); + let sides = crate::sketch_dims::locked_value(app, t, "Sides").map_or(6, |v| v as usize); let params = match t.cmd { "sketch.rectangle.two_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}), "sketch.rectangle.center" => json!({"center": xy(&p[0]), "corner": xy(&p[1])}), @@ -217,7 +266,10 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { "sketch.circle.two_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}), "sketch.circle.three_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "p2": xy(&p[2])}), "sketch.arc.three_point" => json!({"start": arg(&p[0]), "end": arg(&p[1]), "through": xy(&p[2])}), - "sketch.arc.center_point" => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}), + "sketch.arc.center_point" => match crate::sketch_dims::locked_value(app, t, "Sweep") { + Some(sweep) => json!({"center": arg(&p[0]), "start": arg(&p[1]), "sweep": sweep.to_degrees()}), + None => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}), + }, "sketch.polygon.inscribed" | "sketch.polygon.circumscribed" => { let a = (p[1].0 - p[0].0).angle().to_degrees(); json!({"center": xy(&p[0]), "radius": d(0, 1), "sides": sides, "angle": a}) @@ -229,14 +281,10 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "width": w.max(1e-3)}) } "sketch.point" => json!({"point": xy(&p[0])}), - _ => return Vec::new(), + _ => return Err(format!("drawing {} is not supported", t.cmd)), }; - let curves: Vec = app - .run(t.cmd, params) - .ok() - .map(|v| v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect()) - .unwrap_or_default(); - curves + let result = draw(app, t, params)?; + Ok(result["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect()) } fn run_pick(app: &mut SolveApp, t: &Tool) { @@ -268,6 +316,7 @@ pub fn finish(app: &mut SolveApp) { { t.pts.clear(); t.picks.clear(); + crate::sketch_dims::reset(t); return; } app.tool = None; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 300f3521..9c6d4dd2 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -214,10 +214,12 @@ fn scene_key(app: &SolveApp) -> u64 { (app.cam.pitch > 0.0).hash(&mut h); app.ui.show_sketches.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_occurrences.hash(&mut h); app.session.visibility.hidden_sketches.hash(&mut h); app.session.visibility.shown_sketches.hash(&mut h); app.ui.hidden_profiles.hash(&mut h); app.session.active_component.hash(&mut h); + app.session.active_occurrence_path.hash(&mut h); app.preview.replaced.hash(&mut h); crate::theme::is_dark().hash(&mut h); app.session.active_sketch.hash(&mut h); @@ -264,12 +266,17 @@ fn build_scene(app: &SolveApp) -> GpuScene { sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w); } } + let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok()); for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) { + if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { continue; } // With a component active, bodies outside it are drawn faded. - let faded = s.active_component != 0 && !s.doc.component_within(s.doc.body_component(&b.name, b.feature), s.active_component); + let faded = if let Some(names) = &active_bodies { + !names.contains(&b.name) + } else { + s.active_component != 0 && !s.doc.component_within(b.component, s.active_component) + }; let base = body_color(&s.doc, b).unwrap_or(tk.body); let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) }; let m = b.mesh(); @@ -283,7 +290,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { let hidden_edges = app.ui.visual_style == 3; let hidden_col = c4(tk.text.gamma_multiply(0.35)); // Appearances: the body's opacity, and faces with looks of their own. - let opacity = s.doc.body_look(&b.name, b.feature).map_or(1.0, |l| l.opacity); + let opacity = s.doc.model_body_look(b).map_or(1.0, |l| l.opacity); let face_looks: std::collections::HashMap = if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() }; for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded) { @@ -329,7 +336,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { // Section: the outline of each body where the plane cuts it. if let Some((o, n)) = section_plane(app) { let col = c4(tk.text.gamma_multiply(0.9)); - for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)) { + for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) { for (a, c) in section_segments(&b.mesh(), o, n) { sc.line(a.to_f32(), c.to_f32(), col, 1.8, false); } @@ -370,7 +377,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) { // The bodies' box in the camera's Z-up frame (the ground is its XY plane). let mut b = solvecraft_engine::geom::Aabb3::EMPTY; - for x in st.bodies.iter().filter(|x| !app.session.visibility.hidden_bodies.contains(&x.name)) { + for x in st.bodies.iter().filter(|x| app.session.body_visible(&x.name)) { let bb = x.mesh().bounds(); if bb.is_empty() { continue; @@ -632,6 +639,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { app.session.active_sketch.hash(&mut h); app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_occurrences.hash(&mut h); app.ui.perspective.hash(&mut h); (proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h); let key = h.finish(); @@ -707,7 +715,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { // Nearest face along the ray (hides what is behind it). let mut bestf: Option<(f64, Hit)> = None; for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) { + if !app.session.body_visible(&b.name) { continue; } // Bodies whose box the ray misses (or enters behind the best hit so far) are skipped. @@ -733,7 +741,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { let mut bestv: Option<(f32, Hit)> = None; let mut beste: Option<(f32, Hit)> = None; for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) { + if !app.session.body_visible(&b.name) { continue; } // Only bodies whose outline on screen is near the cursor can have an edge under it. @@ -874,7 +882,7 @@ fn feature_geometry(app: &SolveApp, id: u64) -> Vec { let prefix = format!("F{id}:"); let mut out = Vec::new(); for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) { + if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { continue; } if b.feature == id { @@ -900,6 +908,14 @@ fn feature_geometry(app: &SolveApp, id: u64) -> Vec { fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec { match item { Sel::Feature { id } => feature_geometry(app, *id), + Sel::Occurrence { path } => app + .session + .occurrence_bodies(path, true) + .unwrap_or_default() + .into_iter() + .filter(|name| app.session.body_visible(name)) + .map(|name| Sel::Body { name }) + .collect(), _ => grouped_selection(&app.session, item), } } @@ -1120,6 +1136,7 @@ fn highlight_key(app: &SolveApp) -> u64 { app.origin_visible().hash(&mut h); app.session.visibility.hidden_origin.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_occurrences.hash(&mut h); app.cam.half_height().to_bits().hash(&mut h); app.cam.back().x.to_bits().hash(&mut h); h.finish() @@ -1202,14 +1219,14 @@ fn build_highlight(app: &SolveApp) -> GpuScene { match x { Sel::Face { body, index, .. } => { // A live preview stands in for the body: its old faces would fight with it. - if let Some(b) = st.body(body).filter(|_| !app.preview.replaced.contains(body)) { + if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) { face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false); hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge); } } Sel::Body { name } => { if let Some(b) = st.body(name) - && !app.session.visibility.hidden_bodies.contains(name) + && app.session.body_visible(name) { let m = b.mesh(); for f in 0..b.body.face_count() { @@ -1219,6 +1236,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } Sel::Edge { body, index, point } => { if let Some(b) = st.body(body) + && app.session.body_visible(body) && let Some(e) = edge_of(&b.mesh(), *index, *point) { edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0); @@ -1253,7 +1271,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { // Timeline modifiers light only their resulting faces; browser rows light whole bodies. let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default(); hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() })); - for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name) && !app.preview.replaced.contains(&b.name)) { + for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name)) { let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name)); let faces: Vec<_> = if whole { (0..b.body.face_count()).collect() @@ -1936,7 +1954,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { } else { let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES); for b in &st.bodies { - if app.session.visibility.hidden_bodies.contains(&b.name) { + if !app.session.body_visible(&b.name) { continue; } let m = b.mesh(); diff --git a/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json index d11d2245..7c2db2ac 100644 --- a/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json +++ b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json @@ -151,7 +151,7 @@ "key": "Enter" }, { - "widget": "Capture Position" + "widget": "Capture All Positions" }, { "expect": { @@ -199,4 +199,4 @@ { "shot": "s29_moved" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s61_occurrence_browser.json b/crates/ui-egui/tests/scenarios/s61_occurrence_browser.json new file mode 100644 index 00000000..d5f3aa10 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s61_occurrence_browser.json @@ -0,0 +1,22 @@ +[ + {"note":"Each instance has a selectable full path. The second row targets its placement, eye and fold independently."}, + {"call":"engine.execute","params":{"command":"component.create","params":{"name":"Block"}}}, + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10}}}, + {"call":"engine.execute","params":{"command":"component.activate","params":{"component":0}}}, + {"call":"engine.execute","params":{"command":"occurrence.copy","params":{"component":"Block","translate":[50,0,0]}}}, + {"click":{"handle":"row:occurrence:4"}}, + {"expect":{"selection":1}}, + {"call":"ui.menu","params":{"x":300,"y":300,"target":{"type":"occurrence","path":[4]}}}, + {"call":"ui.menuPick","params":{"item":"Properties"}}, + {"expect":{"shown":"(55.00, 5.00, 5.00) mm","query":{"command":"occurrence.properties","params":{"occurrence":4,"path":[4]},"path":["total","center_of_mass",0],"value":55}}}, + {"shot":"second_occurrence_properties"}, + {"click":{"handle":"eye:occurrence:4"}}, + {"click":{"handle":"fold:occurrence:4"}}, + {"shot":"second_occurrence_hidden_folded"}, + {"click":{"handle":"eye:occurrence:4"}}, + {"click":{"handle":"activate:occurrence:4"}}, + {"call":"ui.menu","params":{"x":300,"y":300,"target":{"type":"occurrence","path":[4]}}}, + {"call":"ui.menuPick","params":{"item":"Move/Copy"}}, + {"expect":{"shown":"Move: Block:2"}}, + {"shot":"second_occurrence_move"} +] diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index b38a8e15..5e45fb53 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -28,3 +28,13 @@ consume an undo step. DXF cutout geometry preserves lines, circles and circular arcs; other curves are sampled adaptively. The `OUTLINE`, `BEND_UP` and `BEND_DOWN` layers separate cuts from bend centres. + +Base-flange profiles keep their interior holes. Through cuts that cross a sheet edge become +notches in the flat outline; overlapping and repeated cuts remove their combined area once. +The flat preview, DXF and unfolded solid use the same material boundaries. Lines and circular +arcs remain exact; other profile curves use a 0.001 mm chord tolerance. + +Cuts can leave separate material pieces, including the centre of a ring cut. These pieces +remain one logical sheet group through Unfold / Refold and export as separate flat outlines. +Large profiles that exceed the geometry processing budget report an error instead of +exporting a partial outline. diff --git a/docs/configurations.md b/docs/configurations.md index 43141b28..06edf4eb 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -36,8 +36,66 @@ face appearances keep their existing priority. Physical material changes affect Style columns target body names. Assignments remain stored when a row suppresses the feature that produces that body, and become visible when it returns. Component and face appearance -columns and conditional configuration rules are not yet supported. +columns are not yet supported. -Designs saved with these columns use file format 7. Older designs upgrade on opening without +Activation evaluates all row parameter expressions together, so column order does not create +temporary dependency cycles. Preview and activation reject new or changed geometry errors +without changing the design. An identical existing error remains visible. A saved row cannot +recreate a deleted parameter: remove its configuration references before deleting it. Deleting +a feature removes its input and suppression columns and any rules referring to that feature. + +## Ordered rules and preview + +Open **Add or edit rule** below the table. Give the rule a name, choose when it applies, then +choose a driven parameter, driven suppression, numeric range, or allowed values. Numeric +fields accept ordinary design expressions and explicit units. **Edit**, the arrow buttons, +the enable checkbox, and **Delete** update existing rules. Choose **All**, **Any**, or **Not** +to build compound conditions with nested controls, **Add condition**, and **Remove condition**. + +**Preview Row** rebuilds a scratch design and shows effective expressions and matched or +skipped rules. Driven and restricted values identify the rule responsible. It leaves the +current geometry, active row, and undo history unchanged. **Apply** or **Activate** publishes +the validated variant as one undoable action. + +Rules execute in list order. Each condition sees earlier drives; restrictions from matched +rules all apply to the final values. A range includes its endpoints. Allowed values compare +numeric values with matching dimensions, so `1 cm` equals `10 mm`. An angle never equals a +length. Bare expressions use the target parameter's unit, or the document's unit for feature +length inputs. Equality uses a relative/absolute tolerance of `1e-9` in internal units. +The rule editor's **Length units** selector sets the meaning of bare feature-length +expressions; explicit units override it. Rules capture this unit context when authored. Changing document units +preserves mixed expressions such as `width + 1` without treating `width * 2` as a length +multiplier. Bare saved feature length cells and rule thresholds gain the previous explicit +unit; explicit units and nonliteral source expressions retain their text. A matching active +row rederives its driven values when units change. User parameters retain their own units. + +Rules recompute on configured edits, **Apply**, and **Preview Row**. Ordinary parameter +edits can leave the configuration unmatched and do not continuously enforce restrictions. +Cross-unit drives store effective numeric lengths; their saved source expressions reevaluate +on the next Apply or Preview rather than following later ordinary parameter edits live. +Saved source cells are preserved: disabling a drive restores the row's original choice. + +```json +{"command":"config.rule","params":{"rule":{"id":"depth-rule","name":"Large parts use deeper stock","condition":{"type":"parameter","parameter":"width","operator":"ge","expression":"80 mm"},"effect":{"type":"drive_parameter","parameter":"depth","expression":"width / 2"}}}} +{"command":"config.rule","params":{"rule":{"id":"width-range","name":"Stock width","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"width","minimum":"10 mm","maximum":"100 mm"}}}} +{"command":"config.preview","params":{"row":"Aluminum blue"}} +{"command":"config.rule","params":{"id":"width-range","index":0}} +``` + +Configure the `depth` column before adding this example drive. Conditions may reference any +existing design parameter; effect targets must be configured columns. `config.rules` lists +rules. `config.rule` replaces a rule with the same ID, deletes with `id` and `delete: true`, +or moves it to a zero-based `index`. Editing a rule validates every saved row before committing; +editing a row validates that row. Invalid geometry, dependency cycles, incompatible units, +or restrictions leave the previous document intact and explain the failure. Delete rules +before removing the last row. Parameter renames update rule targets and expressions; rule +references prevent parameter deletion until removed. + +Tables support at most 128 rules; conditions allow eight levels and 64 nodes, and allowed +value lists support up to 128 expressions. These bounds are checked before saved rules are +decoded. Driven appearance/material rules and repeated fixed-point evaluation are outside +the current rule scope. + +Designs saved with rules use file format 8. Older designs upgrade on opening without changing their existing configuration values. Older SolveCraft versions must be updated to open newly saved designs. diff --git a/docs/occurrence-workflows.md b/docs/occurrence-workflows.md new file mode 100644 index 00000000..d97c21df --- /dev/null +++ b/docs/occurrence-workflows.md @@ -0,0 +1,39 @@ +# Working with individual instances + +Each occurrence placement has a full path of occurrence IDs, from the design root to that placement. A child definition reused under several parent instances has a different full path in each parent. Names are labels; paths identify placements across rename and position capture. + +Select a placement through `select.set`: + +```json +{"items":[{"type":"occurrence","path":[2,4]}]} +``` + +Properties accepts the leaf occurrence ID and the full path. Move can use world coordinates, including a world rotation pivot: + +```json +{"occurrence":4,"path":[2,4],"space":"world","translate":[10,0,0],"capture":false} +``` + +World movement includes pending parent and child positions. The default space remains parent-local for existing scripts. Capture Position commits pending transforms; Revert Position drops them. Copying an occurrence copies that selected occurrence's effective transform, including its pending position. + +An occurrence definition inside a component is shared by every instance of that parent component. Moving, grounding, copying, or deleting that nested definition changes all its parent instances. Selecting its exact path determines the world frame used for movement and Properties; it does not create an independent child definition. + +Activate a placement to author shared geometry through its frame: + +```json +{"component":3,"occurrence":4,"path":[2,4]} +``` + +New geometry belongs to the component and appears in every instance. The selected frame determines how world inputs become local component geometry. Planar sketches display at the active placement during authoring. Component-only activation keeps the first-placement behavior. Existing 3D wires retain their prior coordinate behavior; their mixed legacy coordinate storage needs a separate migration before occurrence authoring can apply consistently. + +Visibility is an independent view setting for exact placements. Hide a path and all descendants with `browser.visibility`: + +```json +{"visible":false,"paths":[[2,4]]} +``` + +`visible:true` removes that path's own hide setting. A hidden ancestor still hides its descendants. Add `isolate:true` to a visible path list to hide other branches. Show the bodies folder to clear occurrence hides. Selection, activation paths and visibility are session state; the saved document retains occurrence IDs and transforms. Undo follows the existing behavior of clearing selection. + +Paths are checked for missing links, repeated IDs, component mismatches and a maximum depth of 64. Descendant expansion rejects more than 100,000 placements or bodies instead of returning incomplete results. Computed body names normally retain their readable occurrence labels; collisions use full occurrence IDs so visibility and body commands still address the correct source. + +Placed bodies retain their definition identity separately from display names. Body materials and appearances remain shared, with body overrides taking precedence over component overrides and material colors. Face overrides follow the placed geometry, including pending positions, and painted exports retain these colors. diff --git a/docs/parity.md b/docs/parity.md index 32d6be9e..c067fdfe 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,16 +2,16 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 331 / 541 commands live (61%).** SOLID + SKETCH: 186 / 291 (64%). Registered commands: 307. +**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 335 / 541 commands live (62%).** SOLID + SKETCH: 188 / 291 (65%). Registered commands: 311. -Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 623 / 1175 (53%). +Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 630 / 1175 (54%). | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| | SOLID | CREATE | 23 | 35 | 66% | | SOLID | MODIFY | 19 | 32 | 59% | | SOLID | ASSEMBLE | 12 | 21 | 57% | -| SOLID | CONFIGURE | 2 | 3 | 67% | +| SOLID | CONFIGURE | 3 | 3 | 100% | | SOLID | CONSTRUCT | 19 | 20 | 95% | | SOLID | INSPECT | 12 | 25 | 48% | | SOLID | INSERT | 6 | 11 | 55% | @@ -20,7 +20,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SKETCH | CREATE | 41 | 41 | 100% | | SKETCH | MODIFY | 12 | 12 | 100% | | SKETCH | CONSTRAINTS | 15 | 15 | 100% | -| SKETCH | CONFIGURE | 2 | 3 | 67% | +| SKETCH | CONFIGURE | 3 | 3 | 100% | | SKETCH | INSPECT | 12 | 25 | 48% | | SKETCH | INSERT | 7 | 13 | 54% | | SKETCH | ASSEMBLE | 0 | 1 | 0% | @@ -29,7 +29,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SHEET METAL | CREATE | 15 | 19 | 79% | | SHEET METAL | MODIFY | 12 | 21 | 57% | | SHEET METAL | ASSEMBLE | 11 | 20 | 55% | -| SHEET METAL | CONFIGURE | 2 | 3 | 67% | +| SHEET METAL | CONFIGURE | 3 | 3 | 100% | | SHEET METAL | CONSTRUCT | 19 | 20 | 95% | | SHEET METAL | INSPECT | 12 | 25 | 48% | | SHEET METAL | INSERT | 6 | 11 | 55% | @@ -42,7 +42,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | ASSEMBLY | MOTION | 3 | 3 | 100% | | ASSEMBLY | MODIFY | 8 | 11 | 73% | | ASSEMBLY | CONTACT | 2 | 2 | 100% | -| ASSEMBLY | CONFIGURE | 2 | 3 | 67% | +| ASSEMBLY | CONFIGURE | 3 | 3 | 100% | | ASSEMBLY | CONSTRUCT | 19 | 20 | 95% | | ASSEMBLY | INSPECT | 12 | 25 | 48% | | ASSEMBLY | SELECT | 0 | 15 | 0% | @@ -56,7 +56,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | SURFACE | CREATE | 9 | 25 | 36% | | SURFACE | MODIFY | 17 | 26 | 65% | | SURFACE | ASSEMBLE | 11 | 20 | 55% | -| SURFACE | CONFIGURE | 2 | 3 | 67% | +| SURFACE | CONFIGURE | 3 | 3 | 100% | | SURFACE | CONSTRUCT | 19 | 20 | 95% | | SURFACE | INSPECT | 12 | 25 | 48% | | SURFACE | INSERT | 6 | 11 | 55% | @@ -66,7 +66,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | MESH | PREPARE | 0 | 4 | 0% | | MESH | MODIFY | 6 | 30 | 20% | | MESH | ASSEMBLE | 12 | 21 | 57% | -| MESH | CONFIGURE | 2 | 3 | 67% | +| MESH | CONFIGURE | 3 | 3 | 100% | | MESH | CONSTRUCT | 19 | 20 | 95% | | MESH | INSPECT | 12 | 25 | 48% | | MESH | INSERT | 5 | 10 | 50% | @@ -87,7 +87,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | PLASTIC | CREATE | 27 | 35 | 77% | | PLASTIC | MODIFY | 19 | 32 | 59% | | PLASTIC | ASSEMBLE | 11 | 20 | 55% | -| PLASTIC | CONFIGURE | 2 | 3 | 67% | +| PLASTIC | CONFIGURE | 3 | 3 | 100% | | PLASTIC | CONSTRUCT | 19 | 20 | 95% | | PLASTIC | INSPECT | 12 | 26 | 46% | | PLASTIC | INSERT | 6 | 11 | 55% | @@ -166,6 +166,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › ASSEMBLE › New Contact Set - SOLID › CONFIGURE › Configure - SOLID › CONFIGURE › Display Configuration Table +- SOLID › CONFIGURE › Create Configuration Rules - SOLID › CONSTRUCT › Offset Plane - SOLID › CONSTRUCT › Plane at Angle - SOLID › CONSTRUCT › Tangent Plane @@ -275,6 +276,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SKETCH › CONSTRAINTS › Polygon - SKETCH › CONFIGURE › Configure - SKETCH › CONFIGURE › Display Configuration Table +- SKETCH › CONFIGURE › Create Configuration Rules - SKETCH › INSPECT › Measure - SKETCH › INSPECT › Interference - SKETCH › INSPECT › Section Analysis @@ -332,7 +334,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › ASSEMBLE › Tangent Constraint - SOLID › ASSEMBLE › Joint Constraint - SOLID › ASSEMBLE › As-Built Constraint -- SOLID › CONFIGURE › Create Configuration Rules - SOLID › CONSTRUCT › User Coordinate System (UCS) - SOLID › INSPECT › Validate - SOLID › INSPECT › Component Connection Analysis @@ -368,7 +369,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE - SOLID › SELECT › Select Edge Priority - SOLID › SELECT › Select Component Priority - SOLID › SELECT › Selection Filters -- SKETCH › CONFIGURE › Create Configuration Rules - SKETCH › INSPECT › Validate - SKETCH › INSPECT › Component Connection Analysis - SKETCH › INSPECT › DOF Analysis diff --git a/xtask/data/fusion-ids.tsv b/xtask/data/fusion-ids.tsv index 79b533d1..e61809ad 100644 --- a/xtask/data/fusion-ids.tsv +++ b/xtask/data/fusion-ids.tsv @@ -56,6 +56,7 @@ DisableAllContactCmd contact.disable_all ContactSetCmd contact.create FusionStartDesignConfigModeCmd config.configure FusionShowDesignConfigPanelCmd config.table +FusionConfigurationRulesCmd config.rules ConstructionPlaneOffsetFromPlaneCommand construct.plane.offset ConstructionPlaneAtAngleCommand construct.plane.at_angle ConstructionTangentPlaneCommand construct.plane.tangent