From 7a2eb059c4eee60b1570d8a80bd4738f6c9a22fb Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 14:23:35 +0000 Subject: [PATCH] Components are frames (user bug #4) What a component holds moves with its occurrence, and picks in the world land in the component's frame: - Sketches and construction planes show where their component is placed (free move, joint drive, Capture Position); sketch.inspect reports sketch points in the world. - frames.rs maps a command's world picks into the frame of the body they lie on (through the inverse of its placement: a moved component or a pasted instance), points that make new geometry into the active component's frame, and directions with them; instance body names ("Body1 (Pin:2)") become the component's body. Fillet/chamfer edges, shell/offset/draft faces, face extrude/revolve, sketch on face, holes, sheet metal and plastic picks, primitives, appearance faces, analyses. Once per command (not again for commands run by commands); the active-frame fallback stays for commands without a table. Nothing changes while every placement is the identity. - component.move_bodies keeps a body where it is in the world (Document.body_offsets: from the new component's frame to the one it was made in; renames and deleted components follow). - The UI reads the placed model (Session::world_state) for display, picking, sketch editing and dialogs; live previews use it once anything is placed. Tests (engine): sketches and construction planes move (and turn) with the component; world picks fillet, sketch, hole and make boxes in a moved component; a pick on a pasted instance; move_bodies keeps the world place there and back; a sketch on a face follows a Move feature. QA's s34 now runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/assembly.rs | 37 ++ crates/doc/src/document.rs | 5 + crates/doc/src/eval.rs | 5 +- crates/engine/src/cmd/browser.rs | 3 + crates/engine/src/cmd/component.rs | 22 +- crates/engine/src/cmd/inspect.rs | 5 +- crates/engine/src/frames.rs | 358 +++++++++++++++++++ crates/engine/src/frames_tests.rs | 146 ++++++++ crates/engine/src/lib.rs | 6 + crates/engine/src/view.rs | 16 +- crates/ui-egui/src/browser.rs | 4 +- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/context_menu.rs | 12 +- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/dialogs.rs | 24 +- crates/ui-egui/src/dialogs_assembly.rs | 7 +- crates/ui-egui/src/dialogs_assembly_tests.rs | 5 +- crates/ui-egui/src/dialogs_motion.rs | 2 +- crates/ui-egui/src/dialogs_sheet.rs | 8 +- crates/ui-egui/src/dim_view.rs | 2 +- crates/ui-egui/src/help.rs | 2 +- crates/ui-egui/src/inference.rs | 2 +- crates/ui-egui/src/sketch3d.rs | 10 +- crates/ui-egui/src/sketch_dims.rs | 2 +- crates/ui-egui/src/sketch_palette.rs | 4 +- crates/ui-egui/src/sketch_tools.rs | 14 +- crates/ui-egui/src/timeline.rs | 4 +- crates/ui-egui/src/tools.rs | 6 +- crates/ui-egui/src/viewport.rs | 20 +- crates/ui-egui/tests/scenarios.rs | 2 +- 30 files changed, 669 insertions(+), 72 deletions(-) create mode 100644 crates/engine/src/frames.rs create mode 100644 crates/engine/src/frames_tests.rs diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 878309d8..35b0c730 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -298,6 +298,43 @@ impl Document { out } + /// Every body as the world shows it: its world name ("Body1", or "Body1 (Occ:2)" for a + /// 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)> { + let mut out = Vec::new(); + for (comp, path, m) in self.placements() { + let primary = path == self.first_path(comp); + let label = || -> String { + path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::>().join("/") + }; + for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) { + let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) }; + let w = match self.body_offsets.get(&b.name) { + Some(o) => mat_mul(&m, o), + None => m, + }; + out.push((name, comp, path.clone(), w)); + } + } + out + } + + /// The occurrence path of a component's first placement. + pub fn first_path(&self, component: u64) -> Vec { + let mut path = Vec::new(); + let mut c = component; + for _ in 0..1000 { + if c == 0 { + break; + } + let Some(o) = self.occurrence_of(c) else { break }; + path.insert(0, o.id); + c = o.parent; + } + path + } + /// Is `a` inside `b` (or `b` itself)? pub fn component_within(&self, a: u64, b: u64) -> bool { let mut c = a; diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 5884930d..72e0acd4 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1128,6 +1128,10 @@ pub struct Document { /// Bodies moved into another component than their feature's (by body name). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub body_components: std::collections::BTreeMap, + /// Bodies moved into another component keep their place in the world: the transform from + /// the new component's frame to the frame the body was made in (by body name). + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub body_offsets: std::collections::BTreeMap, /// Browser groups (folders of bodies, sketches or construction planes). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub browser_groups: Vec, @@ -1196,6 +1200,7 @@ impl Document { configs: Default::default(), parts: Default::default(), body_components: Default::default(), + body_offsets: Default::default(), materials: Default::default(), appearances: Default::default(), favorites: Default::default(), diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index abae58f5..6c3f969d 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2291,7 +2291,10 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { 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 body = if ident { b.body.clone() } else { kernel::transform_matrix(&b.body, m).unwrap_or_else(|_| b.body.clone()) }; + // 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()) }; out.bodies.push(ModelBody::new(name, body, b.feature)); } if primary { diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 9201d635..fda9c657 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -332,6 +332,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) { f.body = name.to_string(); } + if let Some(o) = d.body_offsets.remove(&old) { + d.body_offsets.insert(name.to_string(), o); + } if let Some(c) = d.body_components.remove(&old) { d.body_components.insert(name.to_string(), c); } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 0d716277..68d59ef5 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -156,7 +156,9 @@ fn delete(s: &mut Session, p: &Value) -> Result { } d.components.retain(|c| !gone.contains(&c.id)); d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent)); + let moved_out: Vec = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect(); d.body_components.retain(|_, c| !gone.contains(c)); + d.body_offsets.retain(|b, _| !moved_out.contains(b)); if gone.contains(&s.active_component) { s.active_component = 0; } @@ -174,10 +176,23 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result { for b in &bodies { let feature = st.body(b).map(|x| x.feature).unwrap_or(0); let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0); + // The body keeps its place in the world: where it is shown now (its component's frame and + // any earlier offset), seen from the new component's frame. + let from = s.doc.body_component(b, feature); + let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY)); + let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to)) + .map(|inv| solvecraft_doc::mat_mul(&inv, &now)) + .unwrap_or(solvecraft_doc::IDENTITY); + let doc = s.doc_mut(); if own == to { - s.doc_mut().body_components.remove(b); + doc.body_components.remove(b); } else { - s.doc_mut().body_components.insert(b.clone(), to); + doc.body_components.insert(b.clone(), to); + } + if solvecraft_doc::is_identity(&offset) { + doc.body_offsets.remove(b); + } else { + doc.body_offsets.insert(b.clone(), offset); } } Ok(json!({"bodies": bodies, "component": to})) @@ -355,7 +370,8 @@ fn revert(s: &mut Session, _p: &Value) -> Result { /// The active component's frame from world coordinates (for new features' geometry). pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) { - if s.active_component == 0 { + // Commands whose picks `frames` maps are already in their frames. + if s.active_component == 0 || crate::frames::mapped() { return; } let m = s.doc.component_transform(s.active_component); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 9c2bce00..3d79f218 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -102,6 +102,9 @@ fn measure(s: &mut Session, p: &Value) -> Result { fn sketch_json(s: &Session, id: u64) -> Option { let state = s.model.state(); let ss = state.sketch(id)?; + // Where the sketch is in the world (its component's placement). + let world = s.world_state(); + let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane); let sk = &ss.sketch; let curves: Vec = sk .curves @@ -129,7 +132,7 @@ fn sketch_json(s: &Session, id: u64) -> Option { .points .iter() .enumerate() - .map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)})) + .map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)})) .collect(); let constraints: Vec = sk .constraints diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs new file mode 100644 index 00000000..3302c401 --- /dev/null +++ b/crates/engine/src/frames.rs @@ -0,0 +1,358 @@ +//! Component frames for picks. The viewport shows the placed model (`Session::world_state`): +//! a component's bodies and sketches moved by its occurrence (and pasted instances moved by +//! theirs). Features are made and kept in their component's own frame. So a command's picked +//! points and directions, given in the world, are mapped into the frame of the body they lie on +//! (through the inverse of that body's placement), and points that create geometry into the +//! active component's frame, before the command runs. Instance body names ("Body1 (Occ:2)") +//! become the component's body name. +//! +//! Nothing changes while every placement is the identity (the common case, and every design +//! without moved components). + +use serde_json::{Map, Value}; +use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, mat_inverse}; +use solvecraft_geom::Vec3; + +use crate::Session; + +/// How a parameter is mapped. +#[derive(Clone, Copy, PartialEq)] +enum K { + /// A point on a body (a pick): into that body's frame. + Pick, + /// A list of picks: `[[x,y,z]…]` or `[{point|face|edge: [x,y,z], body?}…]`. + Picks, + /// A point that places new geometry: into the active component's frame. + Make, + /// A direction: turned with the command's frame. + Dir, + /// A plane object (`{face}`, `{origin, normal}`, `{origin, x_dir, y_dir}`) or a plane name. + Plane, + /// A body name, or a list of them. + Body, +} + +use K::*; + +/// The parameters each command takes in world coordinates. +fn table(id: &str) -> &'static [(&'static str, K)] { + match id { + "Extrude" | "Revolve" => &[("face", Pick), ("targets", Body), ("participants", Body)], + "PrimitiveBox" | "PrimitiveSphere" => &[("corner", Make), ("center", Make)], + "PrimitiveCylinder" | "PrimitiveCoil" | "PrimitiveTorus" => &[("base", Make), ("center", Make), ("axis", Dir)], + "FusionFilletEdgesCommand" | "FusionChamferCommand" => &[("edges", Picks), ("body", Body)], + "FusionHoleCommand" => &[("position", Pick), ("direction", Dir), ("body", Body)], + "ModifyScale" => &[("origin", Make), ("bodies", Body)], + "FusionOffsetFacesCommand" | "FusionShellBodyCommand" => &[("faces", Picks), ("body", Body)], + "FusionThreadCommand" => &[("face", Pick)], + "FusionDraftCommand" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)], + "FusionPressPullCommand" => &[("face", Pick), ("edges", Picks)], + "FusionReplaceFaceCommand" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)], + "AlignCmd" => &[("from", Pick), ("from_face", Pick), ("to", Pick), ("to_face", Pick), ("bodies", Body)], + "MirrorCommand" | "FusionSplitBodyCommand" => &[("plane", Plane), ("bodies", Body), ("body", Body)], + "FusionBossCommand" | "FusionSnapFitCommand" | "FusionRestCommand" => { + &[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)] + } + "FusionLipCommand" | "ConvertToSheetMetalCmd" => &[("face", Pick), ("body", Body)], + "FusionSheetMetalFlangeCommand" | "FusionSheetMetalHemFlangeCommand" => &[("edges", Picks), ("body", Body)], + "SheetMetalFoldCmd" => &[("points", Picks), ("fixed", Pick), ("body", Body)], + "SketchCreate" | "sketch.redefine" => &[("plane", Plane)], + "FusionAddEditDecalCommand" => &[("face", Pick)], + "IntersectCmd" => &[("refs", Picks)], + "ProjectToSurface" | "SketchIsoparametricCurve" => &[("face", Pick), ("body", Body)], + "IntersectionCurve" => &[("a", Picks), ("b", Picks)], + "FusionCurvatureCombAnalysisCommand" | "FusionMinimumRadiusAnalysisCommand" | "FusionIsoCurveAnalysisCommand" => { + &[("edges", Picks), ("faces", Picks)] + } + "AppearanceCommand" => &[("faces", Picks), ("bodies", Body), ("body", Body)], + "FusionMoveCommand" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Make)], + "FusionCombineCommand" => &[("target", Body), ("tools", Body)], + "SoftDeleteCommand" | "StockModelCommand" | "PhysicalMaterialCommand" => &[("bodies", Body)], + _ => &[], + } +} + +/// The world placement of bodies (name → frame), with moves not captured yet. +struct Frames { + bodies: Vec<(String, String, Mat)>, + active: Mat, +} + +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) + }; + // 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(); + Frames { bodies, active: doc.component_transform(s.active_component) } +} + +/// Does any placement move anything (so the world differs from the components' frames)? +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)) +} + +fn vec3(v: &Value) -> Option { + let a = v.as_array()?; + if a.len() != 3 { + return None; + } + let c = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()); + Some(Vec3::new(c(0)?, c(1)?, c(2)?)) +} + +fn pt(p: Vec3) -> Value { + serde_json::json!([p.x, p.y, p.z]) +} + +/// 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(" ("))) + { + return Some(*m); + } + let world = s.world_state(); + let mut best: Option<(f64, Mat)> = None; + for wb in &world.bodies { + let Some((_, _, m)) = f.bodies.iter().find(|(w, _, _)| *w == wb.name) else { continue }; + let mesh = wb.mesh(); + let bb = mesh.bounds(); + let tol = (bb.diagonal() * 1e-3).max(1e-3); + if p.x < bb.min.x - tol + || p.y < bb.min.y - tol + || p.z < bb.min.z - tol + || p.x > bb.max.x + tol + || p.y > bb.max.y + tol + || p.z > bb.max.z + tol + { + continue; + } + let d = mesh.triangles.iter().filter_map(|t| mesh.tri(t)).map(|[a, b, c]| tri_dist(p, a, b, c)).fold(f64::INFINITY, f64::min); + if d <= tol && best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, *m)); + } + } + best.map(|(_, m)| m) +} + +fn tri_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { + let n = (b - a).cross(c - a); + let Some(nn) = n.normalized() else { return p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)) }; + let h = (p - a).dot(nn); + let q = p - nn * h; + let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12); + if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) } +} + +struct Mapper<'a> { + s: &'a Session, + f: Frames, + /// The frame of the command's first pick (its directions turn with it). + first: Option, + changed: bool, +} + +impl Mapper<'_> { + fn pick(&mut self, v: &Value, body: Option<&str>) -> Option { + let p = vec3(v)?; + let m = frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active); + self.first.get_or_insert(m); + self.local_point(&m, p) + } + + fn local_point(&mut self, m: &Mat, p: Vec3) -> Option { + if is_identity(m) { + return None; + } + let inv = mat_inverse(m)?; + self.changed = true; + Some(pt(apply_point(&inv, p))) + } + + fn make(&mut self, v: &Value) -> Option { + let p = vec3(v)?; + let m = self.f.active; + self.local_point(&m, p) + } + + fn dir(&mut self, v: &Value) -> Option { + let d = vec3(v)?; + let m = self.first.unwrap_or(self.f.active); + if is_identity(&m) { + return None; + } + let inv = mat_inverse(&m)?; + self.changed = true; + Some(pt(apply_vector(&inv, d))) + } + + /// A list of picks (points, or objects holding one). + fn picks(&mut self, v: &Value, body: Option<&str>) -> Option { + match v { + Value::Array(a) if vec3(v).is_some() && a.iter().all(Value::is_number) => self.pick(v, body), + Value::Array(a) => { + let out: Vec = a.iter().map(|x| self.picks(x, body).unwrap_or_else(|| x.clone())).collect(); + Some(Value::Array(out)) + } + Value::Object(o) => { + let b = o.get("body").and_then(Value::as_str).map(str::to_string); + let mut m = o.clone(); + for k in ["point", "face", "edge", "vertex", "at"] { + if let Some(x) = o.get(k) + && let Some(y) = self.pick(x, b.as_deref().or(body)) + { + m.insert(k.into(), y); + } + } + if let Some(bn) = &b + && let Some(own) = self.own_name(bn) + { + m.insert("body".into(), Value::String(own)); + } + Some(Value::Object(m)) + } + _ => None, + } + } + + fn plane(&mut self, v: &Value) -> Option { + let o = v.as_object()?; + let mut m = o.clone(); + if let Some(x) = o.get("face").and_then(|x| self.pick(x, None)) { + m.insert("face".into(), x); + } + if let Some(x) = o.get("origin").and_then(|x| self.pick(x, None)) { + m.insert("origin".into(), x); + } + for k in ["normal", "x_dir", "y_dir"] { + if let Some(x) = o.get(k).and_then(|x| self.dir(x)) { + m.insert(k.into(), x); + } + } + Some(Value::Object(m)) + } + + /// The component's body name for a world (instance) body name. + fn own_name(&mut self, world: &str) -> Option { + let own = self.f.bodies.iter().find(|(w, _, _)| w == world).map(|(_, own, _)| own.clone())?; + (own != world).then(|| { + self.changed = true; + own + }) + } + + fn body(&mut self, v: &Value) -> Option { + match v { + Value::String(n) => self.own_name(n).map(Value::String), + Value::Array(a) => { + let out: Vec = a.iter().map(|x| self.body(x).unwrap_or_else(|| x.clone())).collect(); + Some(Value::Array(out)) + } + _ => None, + } + } +} + +thread_local! { + static DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The outermost command's coordinates were mapped here (its table lists them). + static MAPPED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Did this module map the running command's coordinates? (Otherwise a new feature's geometry +/// goes into the active component's frame as a whole.) +pub fn mapped() -> bool { + MAPPED.with(std::cell::Cell::get) +} + +/// A command running inside another (its parameters are already in component frames). +pub struct Nested; + +impl Nested { + /// Enter command `id`; true when it is the outermost one. + pub fn enter(id: &str) -> (Nested, bool) { + let outer = DEPTH.with(|d| { + let n = d.get(); + d.set(n + 1); + n == 0 + }); + if outer { + let inner = if id == "timeline.redefine" { None } else { Some(id) }; + MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty()))); + } + (Nested, outer) + } +} + +impl Drop for Nested { + fn drop(&mut self) { + DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + } +} + +/// `params` with world picks mapped into component frames (`None`: nothing to change). +pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { + if id == "timeline.redefine" { + let cmd = params.get("command")?.as_str()?; + let inner = to_local(s, cmd, params.get("params")?)?; + let mut out = params.clone(); + out["params"] = inner; + return Some(out); + } + let keys = table(id); + if keys.is_empty() || !placed(s) { + return None; + } + let obj = params.as_object()?; + let mut m = Mapper { s, f: frames(s), first: None, changed: false }; + let body = obj.get("body").and_then(Value::as_str).map(str::to_string); + let mut out: Map = obj.clone(); + // Picks first: their frame turns the directions. + for pass in [true, false] { + for (k, kind) in keys { + let Some(v) = obj.get(*k) else { continue }; + let is_pick = matches!(kind, Pick | Picks | Plane); + if is_pick != pass { + continue; + } + let new = match kind { + Pick => m.pick(v, body.as_deref()), + Picks => m.picks(v, body.as_deref()), + Make => m.make(v), + Dir => m.dir(v), + Plane => m.plane(v), + Body => m.body(v), + }; + if let Some(n) = new { + out.insert((*k).to_string(), n); + } + } + } + m.changed.then_some(Value::Object(out)) +} + +#[cfg(test)] +#[path = "frames_tests.rs"] +mod tests; diff --git a/crates/engine/src/frames_tests.rs b/crates/engine/src/frames_tests.rs new file mode 100644 index 00000000..a3b98423 --- /dev/null +++ b/crates/engine/src/frames_tests.rs @@ -0,0 +1,146 @@ +//! Components as frames: what a component holds moves with its occurrence, and picks in the +//! world land in the component's own frame (user bug #4). + +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn near(a: Vec3, b: Vec3) -> bool { + a.dist(b) < 1e-6 +} + +fn world_bbox(s: &Session, body: &str) -> (Vec3, Vec3) { + let w = s.world_state(); + let b = w.body(body).unwrap_or_else(|| panic!("no body {body}")).mesh().bounds(); + (b.min, b.max) +} + +fn sketch_point_world(s: &mut Session, sketch: &str, point: &str) -> Vec3 { + let si = run(s, "sketch.inspect", json!({ "sketch": sketch })); + let p = si["points"].as_array().unwrap().iter().find(|p| p["id"] == point).unwrap()["world"].clone(); + Vec3::new(p[0].as_f64().unwrap(), p[1].as_f64().unwrap(), p[2].as_f64().unwrap()) +} + +fn volume(s: &mut Session) -> f64 { + run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap() +} + +/// A component "Part" with a 30 x 20 x 10 block (sketch "Base" on XY, "Top" on its top face) +/// and a construction plane, moved 50 mm along X. +fn moved_part() -> Session { + let mut s = Session::default(); + run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Part"})); + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Base"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [30, 20]})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"distance": 10})); + run(&mut s, "SketchCreate", json!({"plane": {"face": [15, 10, 10]}, "name": "Top"})); + run(&mut s, "CircleCenterRadius", json!({"center": [15, 10], "radius": 3})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "ConstructionPlaneOffsetFromPlaneCommand", json!({"base": "XY", "offset": 25, "name": "Shelf"})); + run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]})); + s +} + +/// (a) Sketches and construction planes move with their component's occurrence. +#[test] +fn sketches_and_planes_move_with_the_component() { + let mut s = moved_part(); + let (lo, hi) = world_bbox(&s, "Body1"); + assert!(near(lo, Vec3::new(50.0, 0.0, 0.0)) && near(hi, Vec3::new(80.0, 20.0, 10.0)), "{lo:?} {hi:?}"); + assert!(near(sketch_point_world(&mut s, "Base", "p1"), Vec3::new(50.0, 0.0, 0.0))); + let c = sketch_point_world(&mut s, "Top", "p1"); + assert!((c.z - 10.0).abs() < 1e-6 && c.x >= 50.0 - 1e-6, "{c:?}"); + let shelf = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Shelf").unwrap().2; + assert!(near(shelf.origin, Vec3::new(50.0, 0.0, 25.0)), "{:?}", shelf.origin); + // A turn about Z carries them too. + run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [50, 0, 0]})); + assert!(near(sketch_point_world(&mut s, "Base", "p3"), Vec3::new(30.0, 30.0, 0.0)), "{:?}", sketch_point_world(&mut s, "Base", "p3")); +} + +/// (b) Picks on a moved component, given in the world, land on its geometry: fillet edges, a +/// sketch on a face, a hole, a shell; new geometry of the active component goes where asked. +#[test] +fn world_picks_map_into_the_component() { + let mut s = moved_part(); + let v0 = volume(&mut s); + // The edge along X at the top front: world (65, 0, 10). + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[65, 0, 10]], "radius": 2})); + assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material"); + // A sketch on the moved top face sits at world z = 10 over the moved block. + run(&mut s, "SketchCreate", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"})); + run(&mut s, "CircleCenterRadius", json!({"center": [20, 10], "radius": 2})); + run(&mut s, "SketchStop", json!({})); + let pad = sketch_point_world(&mut s, "Pad", "p1"); + assert!((pad.z - 10.0).abs() < 1e-6 && pad.x > 50.0, "{pad:?}"); + let v1 = volume(&mut s); + let h = run(&mut s, "FusionHoleCommand", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5})); + assert!(volume(&mut s) < v1 - 1.0, "the hole cut material: {h}"); + // A box made at world (100, 0, 0) in the active (moved) component shows there. + run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0], "operation": "new"})); + let last = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap(); + let (lo, _) = world_bbox(&s, &last); + assert!(near(lo, Vec3::new(100.0, 0.0, 0.0)), "{lo:?}"); +} + +/// (b) A pasted instance's pick fillets the component (every instance). +#[test] +fn picks_on_an_instance_map_through_its_placement() { + let mut s = Session::default(); + run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Pin"})); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "occurrence.copy", json!({"component": "Pin", "translate": [0, 40, 0]})); + let v0 = volume(&mut s); + // An edge of the second instance only (world y = 40). + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[5, 40, 10]], "radius": 2})); + let v1 = volume(&mut s); + assert!(v1 < v0 - 1.0, "{v0} {v1}"); +} + +/// (c) Moving a body into a moved component keeps it where it is in the world. +#[test] +fn moving_bodies_between_components_keeps_their_place() { + let mut s = moved_part(); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "PrimitiveBox", json!({"length": 4, "width": 4, "height": 4, "corner": [0, 40, 0], "body_name": "Loose"})); + let before = world_bbox(&s, "Loose"); + run(&mut s, "component.move_bodies", json!({"bodies": ["Loose"], "component": "Part"})); + let after = world_bbox(&s, "Loose"); + assert!(near(before.0, after.0) && near(before.1, after.1), "{before:?} {after:?}"); + // It moves with the component from now on, and a pick on it still lands. + run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 0, 10]})); + let moved = world_bbox(&s, "Loose"); + assert!(near(moved.0, before.0 + Vec3::new(0.0, 0.0, 10.0)), "{moved:?}"); + let v0 = volume(&mut s); + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[2, 40, 14]], "radius": 1})); + assert!(volume(&mut s) < v0 - 0.1); + // Back to the root: still in place. + run(&mut s, "component.move_bodies", json!({"bodies": ["Loose"], "component": "root"})); + let back = world_bbox(&s, "Loose"); + assert!(near(back.0, moved.0) && near(back.1, moved.1), "{back:?} {moved:?}"); +} + +/// (d) A sketch on a body's face follows the body when a Move feature moves it. +#[test] +fn sketch_on_a_face_follows_a_moved_body() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 20, "width": 20, "height": 10})); + run(&mut s, "SketchCreate", json!({"plane": {"face": [10, 10, 10]}, "name": "OnTop"})); + run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 2})); + run(&mut s, "SketchStop", json!({})); + // The move goes before the sketch in the timeline: the face the sketch is on moves up. + run(&mut s, "timeline.rollTo", json!({"position": 1})); + run(&mut s, "FusionMoveCommand", json!({"bodies": ["Body1"], "translate": [0, 0, 15]})); + run(&mut s, "timeline.rollTo", json!({})); + let c = sketch_point_world(&mut s, "OnTop", "p1"); + assert!((c.z - 25.0).abs() < 1e-6, "{c:?}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c633cbad..9b7bfc66 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] pub mod cmd; +pub mod frames; pub mod params; pub mod recovery; pub mod sample; @@ -297,6 +298,11 @@ impl Session { let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch }; let null = Value::Object(Default::default()); let params = if params.is_null() { &null } else { params }; + // Picks in the world go into their component's frame (once: commands run by commands + // already have them there). + let (_nested, outermost) = frames::Nested::enter(spec.id); + let mapped = if outermost { frames::to_local(self, spec.id, params) } else { None }; + let params = mapped.as_ref().unwrap_or(params); let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params))); let r = match r { Ok(r) => r, diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index c1415e53..780269d3 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -189,7 +189,21 @@ pub fn construction_planes(s: &Session) -> Vec<(u64, String, Plane)> { .features .iter() .filter_map(|f| match &f.kind { - FeatureKind::ConstructionPlane { plane } if !f.suppressed => s.doc.resolve_plane(&vals, plane, 0).ok().map(|p| (f.id, f.name.clone(), p)), + FeatureKind::ConstructionPlane { plane } if !f.suppressed => s.doc.resolve_plane(&vals, plane, 0).ok().map(|p| { + // Shown where its component is placed. + let m = s.doc.component_transform(f.component); + let p = if solvecraft_doc::is_identity(&m) { + p + } else { + Plane::new( + solvecraft_doc::apply_point(&m, p.origin), + solvecraft_doc::apply_vector(&m, p.x), + solvecraft_doc::apply_vector(&m, p.y), + ) + .unwrap_or(p) + }; + (f.id, f.name.clone(), p) + }), _ => None, }) .collect() diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 2e46f54b..45e14b11 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -90,7 +90,7 @@ fn find_group(app: &SolveApp, id: u64) -> Option<&solvecraft_engine::doc::Browse /// The bodies in a group (none for sketch and plane groups). pub fn group_bodies(app: &SolveApp, id: u64) -> Vec { - let st = app.session.model.state(); + let st = app.session.world_state(); find_group(app, id) .filter(|g| g.folder == "bodies") .map(|g| g.items.iter().filter(|b| st.body(b).is_some()).cloned().collect()) @@ -172,7 +172,7 @@ pub fn toggle_folder(app: &mut SolveApp, component: u64, folder: &str) { pub fn selection_group(app: &SolveApp, target: &Target) -> Option { let (comp, folder) = match target { Target::Body { name } => { - let st = app.session.model.state(); + let st = app.session.world_state(); let b = st.body(name)?; (app.session.doc.body_component(name, b.feature), "bodies") } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ced9cb1b..9fc4b37b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -18,7 +18,7 @@ use crate::viewport::Proj; /// Where the dialog's geometry is: a base point and, for values that can be dragged, the /// direction a growing value points. pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { - let st = app.session.model.state(); + let st = app.session.world_state(); let first = d.inputs.first()?.items.first()?; let (at, normal) = match first { Sel::Profile { sketch, index } => { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 4a7ffc54..9e51141c 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -212,7 +212,7 @@ fn touched_bodies(app: &SolveApp) -> Vec { } fn body_feature(app: &SolveApp, body: &str) -> Option { - app.session.model.state().body(body).map(|b| b.feature) + app.session.world_state().body(body).map(|b| b.feature) } /// The bodies a target stands for. @@ -451,7 +451,7 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec { let profiles = !app.ui.hidden_profiles.contains(&id); let dims = app.ui.shown_dims.contains(&id); let editing = app.session.active_sketch == Some(id); - let has_profiles = app.session.model.state().sketch(id).is_some_and(|s| !s.profiles.is_empty()); + let has_profiles = app.session.world_state().sketch(id).is_some_and(|s| !s.profiles.is_empty()); vec![ act("ui.extrudeSketch", "Extrude", "extrude").key("E").with(json!({ "sketch": id })).on(has_profiles && !editing), Item::sep(), @@ -540,7 +540,7 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { fn sketch_entity_items(app: &SolveApp) -> Vec { use solvecraft_engine::sketch::CurveKind; let Some(sid) = app.session.active_sketch else { return Vec::new() }; - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = st.sketch(sid) else { return Vec::new() }; let mut lines = Vec::new(); let mut rounds = Vec::new(); @@ -665,7 +665,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { app.ui.hidden_sketches.clear(); } "ui.isolate" => { - app.ui.hidden_bodies = app.session.model.state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect(); + app.ui.hidden_bodies = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect(); } "ui.lock" => { let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b)); @@ -844,7 +844,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } "ui.extrudeSketch" => { if let Some(id) = id_of(&p, "sketch") { - let n = app.session.model.state().sketch(id).map_or(0, |s| s.profiles.len()); + let n = app.session.world_state().sketch(id).map_or(0, |s| s.profiles.len()); let items: Vec = (0..n).map(|i| json!({"type": "profile", "sketch": id, "index": i})).collect(); let _ = app.run("select.set", json!({ "items": items })); app.start("Extrude"); @@ -949,7 +949,7 @@ pub fn rename_selection(app: &mut SolveApp, at: Pos2) -> bool { /// Add the edges that continue the selected edges smoothly to the selection. fn tangent_chain(app: &mut SolveApp) { - let st = app.session.model.state(); + let st = app.session.world_state(); let mut out: Vec = Vec::new(); for s in &app.session.selection { let Sel::Edge { body, index, .. } = s else { continue }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c34c67c7..8613bfff 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -90,7 +90,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); let r = app.run(id, params); - if r.is_ok() && app.session.active_sketch.is_none() && app.session.model.state().bodies.len() == 1 && app.session.undo.len() <= 2 { + if r.is_ok() && app.session.active_sketch.is_none() && app.session.world_state().bodies.len() == 1 && app.session.undo.len() <= 2 { app.fit_view(); } wrap(r) @@ -269,7 +269,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> let world = if let Some(w) = p.get("world") { Some(solvecraft_engine::geom::Vec3::new(num(w, 0), num(w, 1), num(w, 2))) } else if let Some(s) = p.get("sketch") { - let st = app.session.model.state(); + let st = app.session.world_state(); app.session .active_sketch .and_then(|id| st.sketch(id)) @@ -311,7 +311,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.sketchToScreen" => { // Screen points of active-sketch coordinates (for driving sketch interaction). - let st = app.session.model.state(); + let st = app.session.world_state(); let (Some(rect), Some(ss)) = (app.viewport.rect, app.session.active_sketch.and_then(|s| st.sketch(s))) else { return err("no active sketch in a drawn viewport"); }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d825274f..a9cff5f3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -278,7 +278,7 @@ impl Dialog { pub fn for_command(app: &SolveApp, id: &str) -> Option { let s = &app.session; - let has_bodies = !s.model.state().bodies.is_empty(); + let has_bodies = !s.world_state().bodies.is_empty(); let mut d = match id { "SketchCreate" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]), "Extrude" => Dialog::new( @@ -614,7 +614,7 @@ impl Dialog { // Tangent chain: an edge brings the edges that continue it smoothly. if chain && let Sel::Edge { body, index, .. } = &sel - && let Some(b) = s.model.state().body(body) + && let Some(b) = s.world_state().body(body) { let m = b.mesh(); picked = m @@ -660,7 +660,7 @@ impl Dialog { /// Profiles of the sketch a feature would use (active, else the last), when there is just one. fn default_profiles(s: &Session) -> Vec { - let st = s.model.state(); + let st = s.world_state(); let sid = s.active_sketch.or_else(|| s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id)); match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) { Some((id, 1)) => vec![Sel::Profile { sketch: id, index: 0 }], @@ -670,7 +670,7 @@ fn default_profiles(s: &Session) -> Vec { /// The plane of a planar body face: (point on it, unit normal). pub fn planar_face(s: &Session, body: &str, face: usize) -> Option<(Vec3, Vec3)> { - let st = s.model.state(); + let st = s.world_state(); let b = st.body(body)?; let tol = (b.body.size() * 1e-3).max(1e-3); let f = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == face)?; @@ -1670,7 +1670,7 @@ pub fn start_sketch(app: &mut SolveApp, sel: &Sel) { /// Animate the camera to look straight at the active sketch plane. pub fn look_at_sketch(app: &mut SolveApp) { - let st = app.session.model.state(); + let st = app.session.world_state(); if let Some(ss) = app.session.active_sketch.and_then(|id| st.sketch(id)) { let mut to = app.cam.looking_from(ss.plane.normal()); // Keep the sketch's own x axis to the right when looking straight down or up. @@ -1781,7 +1781,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Kind::Fillet { radius, chamfer, .. } => { need(0, "edges")?; // Faces stand for all their edges. - let st = s.model.state(); + let st = s.world_state(); let mut pts: Vec = Vec::new(); for x in sels(d, 0) { match x { @@ -2165,7 +2165,7 @@ fn pattern_features(s: &Session, objects: &[Sel]) -> Vec { } fn source_features(s: &Session, bodies: &[String]) -> Vec { - let st = s.model.state(); + let st = s.world_state(); let mut features: Vec = Vec::new(); for n in bodies { if let Some(f) = st.body(n).and_then(|b| s.doc.feature(b.feature)).map(|f| f.name.clone()) @@ -2179,7 +2179,7 @@ fn source_features(s: &Session, bodies: &[String]) -> Vec { /// The sketch holding all these curve ids (the active sketch first, never `not`). fn curves_sketch(app: &SolveApp, ids: &[String], not: Option) -> Option { - let st = app.session.model.state(); + let st = app.session.world_state(); let has = |sid: u64| st.sketch(sid).is_some_and(|ss| ids.iter().all(|id| ss.sketch.curve_index(id).is_some() || ss.sketch.wire_index(id).is_some())); if let Some(a) = app.session.active_sketch @@ -2204,7 +2204,7 @@ pub fn axis_of(app: &SolveApp, sel: &Sel) -> Option<(Vec3, Vec3)> { }, )), Sel::SketchCurve { id } => { - let st = app.session.model.state(); + let st = app.session.world_state(); let sid = curves_sketch(app, std::slice::from_ref(id), None)?; let ss = st.sketch(sid)?; let c = ss.sketch.curves.get(ss.sketch.curve_index(id)?)?; @@ -2240,7 +2240,7 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P /// The edge of a visible body through (or nearest) a point. pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option { - let st = s.model.state(); + let st = s.world_state(); let mut best: Option<(f64, Sel)> = None; for b in &st.bodies { let m = b.mesh(); @@ -2256,7 +2256,7 @@ pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option { /// The body face containing a point (nearest triangle). pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option { - let st = s.model.state(); + let st = s.world_state(); let mut best: Option<(f64, Sel)> = None; for b in &st.bodies { let m = b.mesh(); @@ -2305,7 +2305,7 @@ fn pt3(v: Vec3) -> Value { pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { let s = &app.session; let f = s.doc.feature(id)?.clone(); - let st = s.model.state(); + let st = s.world_state(); let start = |cmd: &str| Dialog::for_command(app, cmd); let mut d = match &f.kind { FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 0af20adf..7b12e855 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -240,7 +240,7 @@ fn pt(v: Vec3) -> Value { /// The occurrence placing a body (0: the root design), when the body is its primary instance. fn occurrence_of_body(s: &Session, body: &str) -> Option { - let st = s.model.state(); + let st = s.world_state(); let b = st.body(body)?; let comp = s.doc.body_component(&b.name, b.feature); if comp == 0 { Some(0) } else { s.doc.occurrence_of(comp).map(|o| o.id) } @@ -769,7 +769,10 @@ fn next_joint_id(s: &Session) -> u64 { /// after is compared, so the components that move show where they go (`None`: not such /// commands). pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Option> { - if cmds.is_empty() || !cmds.iter().all(|(c, _)| MOVES.contains(&c.as_str())) { + // Any feature, when components are placed away from their frames: the preview must show + // the world the viewport shows. + let moves = cmds.iter().all(|(c, _)| MOVES.contains(&c.as_str())); + if cmds.is_empty() || !(moves || solvecraft_engine::frames::placed(s)) { return None; } let mut scratch = s.scratch(); diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 3c798905..192c7dd0 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -101,7 +101,10 @@ fn joint_and_drive_preview_on_the_placed_model() { assert_eq!(cmds[0].0, "FusionMoveJointsCommand"); let b = world_preview(&app.session, &cmds, colors).unwrap().unwrap(); assert_eq!(b.replaced, vec!["Body2".to_string()]); - assert!(world_preview(&app.session, &[("Extrude".into(), json!({}))], colors).is_none(), "features preview as before"); + // Features preview as before while nothing is placed away from its frame, and on the + // placed model once something is (B moved by its joint). + assert!(world_preview(&two_boxes().session, &[("Extrude".into(), json!({}))], colors).is_none()); + assert!(world_preview(&app.session, &[("Extrude".into(), json!({}))], colors).is_some()); } /// Edit Joint changes type, alignment and limits of the joint in place. diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index abc76e39..04ee1461 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -637,7 +637,7 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result = Vec::new(); for x in inputs.first().map(|i| i.items.as_slice()).unwrap_or(&[]) { if let solvecraft_engine::Sel::Body { name } = x - && let Some(b) = app.session.model.state().body(name) + && let Some(b) = app.session.world_state().body(name) { let comp = app.session.doc.body_component(&b.name, b.feature); if let Some(o) = app.session.doc.occurrence_of(comp).map(|o| o.id) diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 57e0b9f3..d346379d 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -434,7 +434,7 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map { let ids: Vec = items(inputs, 0).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); - let st = s.model.state(); + let st = s.world_state(); let sketch = st .sketches .iter() @@ -475,7 +475,7 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map { - let st = s.model.state(); + let st = s.world_state(); let sketch = st .sketches .iter() @@ -543,7 +543,7 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map Option<(Kind, Vec, Map)> { let s = &app.session; - let st = s.model.state(); + let st = s.world_state(); let rule_index = |r: &Option| r.as_ref().and_then(|n| rule_list(app).iter().position(|x| &x.name == n)).map_or(0, |i| i + 1); let mut extra = Map::new(); let (k, items): (Sm, Vec) = match kind { @@ -654,7 +654,7 @@ pub fn arrow(app: &SolveApp, d: &Dialog) -> Option { _ => return None, }; let Sel::Edge { body, index, point } = d.inputs.first()?.items.first()? else { return None }; - let st = app.session.model.state(); + let st = app.session.world_state(); let m = st.body(body)?.mesh(); let faces = m.edge_faces.get(*index)?; let area = |f: u32| -> f64 { diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index b5af5935..32cf9794 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -174,7 +174,7 @@ pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String { /// Draw the active sketch's dimensions and remember where they are for picking. pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { let mut drawn = Vec::new(); - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { DRAWN.with(|d| d.borrow_mut().clear()); EDIT.with(|e| *e.borrow_mut() = None); diff --git a/crates/ui-egui/src/help.rs b/crates/ui-egui/src/help.rs index d209b1c4..cf28803b 100644 --- a/crates/ui-egui/src/help.rs +++ b/crates/ui-egui/src/help.rs @@ -38,7 +38,7 @@ pub fn diagnostics(app: &SolveApp) -> String { if app.custom_titlebar { "custom title bar" } else { "system title bar" }, app.session.doc.name, app.session.doc.features.len(), - app.session.model.state().bodies.len(), + app.session.world_state().bodies.len(), ) } diff --git a/crates/ui-egui/src/inference.rs b/crates/ui-egui/src/inference.rs index cafb1d4a..60f96471 100644 --- a/crates/ui-egui/src/inference.rs +++ b/crates/ui-egui/src/inference.rs @@ -172,7 +172,7 @@ pub fn show(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { return; } let Some(i) = current() else { return }; - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return }; let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); let tk = Tokens::get(); diff --git a/crates/ui-egui/src/sketch3d.rs b/crates/ui-egui/src/sketch3d.rs index 0595c56f..f190ad1a 100644 --- a/crates/ui-egui/src/sketch3d.rs +++ b/crates/ui-egui/src/sketch3d.rs @@ -32,7 +32,7 @@ const ARROW: f32 = 60.0; /// Is the sketch being edited a 3D sketch? pub fn active(app: &SolveApp) -> bool { - let st = app.session.model.state(); + let st = app.session.world_state(); app.session.active_sketch.and_then(|s| st.sketch(s)).is_some_and(|ss| ss.sketch.view.three_d) } @@ -42,7 +42,7 @@ fn takes(cmd: &str) -> bool { /// Points drawn in 3D: (wire id, fit index, point). fn drawn_points(app: &SolveApp) -> Vec<(String, usize, Vec3)> { - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return Vec::new() }; ss.sketch.wires.iter().flat_map(|w| w.fit.iter().enumerate().map(|(i, p)| (w.id.clone(), i, *p))).collect() } @@ -66,7 +66,7 @@ pub fn point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec3, &'stati return Some((p, "Pt")); } } - let st = app.session.model.state(); + let st = app.session.world_state(); let ss = app.session.active_sketch.and_then(|s| st.sketch(s))?; let (o, d) = proj.ray(pos); for h in pick(app, proj, pos) { @@ -92,7 +92,7 @@ pub fn point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec3, &'stati /// The sketch-plane coordinates of `p` when it lies on the plane. fn on_plane(app: &SolveApp, p: Vec3) -> Option { - let st = app.session.model.state(); + let st = app.session.world_state(); let ss = app.session.active_sketch.and_then(|s| st.sketch(s))?; let q = ss.plane.to_local(p); (ss.plane.to_world(q).dist(p) < 1e-6).then_some(q) @@ -305,7 +305,7 @@ fn target(app: &SolveApp, proj: &Proj, pos: Pos2, axis: Option, p: Vec3) - Some(p + u * ((b * d.dot(w0) - c * u.dot(w0)) / den)) } None => { - let st = app.session.model.state(); + let st = app.session.world_state(); let n = app.session.active_sketch.and_then(|s| st.sketch(s))?.plane.normal(); let den = d.dot(n); (den.abs() > 1e-12).then(|| o + d * ((p - o).dot(n) / den)) diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 1f70040d..4d7dba5c 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -233,7 +233,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, proj: &Proj) { fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option { let tk = Tokens::get(); let sid = app.session.active_sketch?; - let st = app.session.model.state(); + let st = app.session.world_state(); let plane = st.sketch(sid)?.plane; let h = t.hover.as_ref().map(|x| x.0)?; let e = effective(app, t, h); diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index a96a9bc2..a7b29e3b 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -61,7 +61,7 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) { return; } let lines: Vec = { - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return }; curves .iter() @@ -96,7 +96,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { return; } let (opts, dof_line, ok) = { - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return }; (ss.sketch.view.clone(), dof_text(ss.report.dof, ss.report.ok()), ss.report.ok() && ss.report.dof == 0) }; diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 0e7569d8..0d1cd0b1 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -476,14 +476,14 @@ fn arg(p: &(Vec2, Option)) -> Value { /// Length of a curve (for default sizes). fn curve_len(app: &SolveApp, id: &str) -> f64 { - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = active_sketch(app).and_then(|s| st.sketch(s)) else { return 10.0 }; ss.sketch.curve_index(id).map(|c| ss.sketch.polyline(c).windows(2).map(|w| w[0].dist(w[1])).sum::()).unwrap_or(10.0) } /// Lines meeting at a sketch point. fn corner_len(app: &SolveApp, point: &str) -> f64 { - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = active_sketch(app).and_then(|s| st.sketch(s)) else { return 10.0 }; let sk = &ss.sketch; let Some(q) = sk.resolve_point(point) else { return 10.0 }; @@ -571,7 +571,7 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { }); // Empty space while sketching: the sketch plane. let target = target.or_else(|| { - let st = app.session.model.state(); + let st = app.session.world_state(); let ss = st.sketch(app.session.active_sketch?)?; let (o, d) = proj.ray(pos); let w = ss.plane.intersect_ray(o, d)?; @@ -850,7 +850,7 @@ fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool { if tool.cmd != "Offset" || tool.picks.is_empty() { return false; } - let st = app.session.model.state(); + let st = app.session.world_state(); let d = active_sketch(app) .and_then(|s| st.sketch(s)) .and_then(|ss| ss.sketch.curve_index(&tool.picks[0]).and_then(|c| ss.sketch.shape(c))) @@ -866,7 +866,7 @@ fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool { /// Rubber band for point tools and pick markers. pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { let Some(sid) = active_sketch(app) else { return }; - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = st.sketch(sid) else { return }; let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); let tk = crate::theme::Tokens::get(); @@ -898,7 +898,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { /// (conflicting ones red). fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let Some(sid) = active_sketch(app) else { return }; - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = st.sketch(sid) else { return }; if ss.sketch.view.hide_constraints { GLYPHS.with(|g| g.borrow_mut().clear()); @@ -995,7 +995,7 @@ fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { return; } let Some((label, p)) = SNAP.with(|s| *s.borrow()) else { return }; - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = active_sketch(app).and_then(|s| st.sketch(s)) else { return }; let Some(sp) = proj.to_screen(ss.plane.to_world(p)) else { return }; let tk = crate::theme::Tokens::get(); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index f870553e..c60cce47 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -112,7 +112,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { .map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone())) .collect(); // The feature whose body is under the cursor in the viewport. - let st = app.session.model.state(); + let st = app.session.world_state(); let from_view = match &app.viewport.hover { Some(crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. }) => { st.body(body).map(|b| b.feature) @@ -298,7 +298,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { /// Select what a feature made, so the browser shows it. fn find_in_browser(app: &mut SolveApp, id: u64) { - let st = app.session.model.state(); + let st = app.session.world_state(); let bodies: Vec = st.bodies.iter().filter(|b| b.feature == id).map(|b| json!({"type": "body", "name": b.name})).collect(); let items = if bodies.is_empty() { vec![json!({"type": "feature", "id": id})] } else { bodies }; let _ = app.run("select.set", json!({ "items": items })); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 4be487dc..4123486a 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -149,7 +149,7 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { }); if let Some((id, is_point)) = hit { tool.picks.push(id.clone()); - let st = app.session.model.state(); + let st = app.session.world_state(); let is_line = app .session .active_sketch @@ -252,7 +252,7 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { fn tie_snaps(app: &mut SolveApp, t: &Tool, curves: &[String]) { let mut ties = Vec::new(); { - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return }; let sk = &ss.sketch; let new_pts: Vec = @@ -319,7 +319,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { return crate::sketch_tools::preview(app, t, painter, proj); } let Some(sid) = app.session.active_sketch else { return }; - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = st.sketch(sid) else { return }; let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); let tk = crate::theme::Tokens::get(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 1419c6c5..9d82b6bd 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -147,7 +147,7 @@ pub fn projection(app: &SolveApp, rect: Rect) -> Proj { /// Sketches drawn in the viewport: the active one, and finished ones not yet used by a feature. fn visible_sketches(app: &SolveApp) -> Vec { - let st = app.session.model.state(); + let st = app.session.world_state(); st.sketches .iter() .filter(|s| { @@ -696,7 +696,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { /// The section cut shown now: the open Section Analysis dialog's plane, else the session's. /// The active sketch's display options (defaults when not sketching). pub fn active_view(app: &SolveApp) -> solvecraft_engine::sketch::SketchView { - let st = app.session.model.state(); + let st = app.session.world_state(); app.session.active_sketch.and_then(|s| st.sketch(s)).map(|ss| ss.sketch.view.clone()).unwrap_or_default() } @@ -710,7 +710,7 @@ pub fn section_plane(app: &SolveApp) -> Option<(Vec3, Vec3)> { } // Slice (Sketch Palette): cut the model at the plane of the sketch being edited. if app.session.section.is_none() { - let st = app.session.model.state(); + let st = app.session.world_state(); if let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)).filter(|ss| ss.sketch.view.slice) { return Some((ss.plane.origin, ss.plane.normal())); } @@ -873,7 +873,7 @@ fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f3 } fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) { - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(ss) = st.sketch(sketch) else { return }; let Some(p) = ss.profiles.get(index) else { return }; // Lift toward the eye so a profile on a face shows over it. @@ -1100,7 +1100,7 @@ fn body_color(doc: &solvecraft_engine::doc::Document, b: &solvecraft_engine::doc /// A body's display colour (else the theme's) as RGB. fn body_rgb(app: &SolveApp, name: &str) -> (u8, u8, u8) { - let st = app.session.model.state(); + let st = app.session.world_state(); let c = st.body(name).and_then(|b| body_color(&app.session.doc, b)).unwrap_or(Tokens::get().body); (c.r(), c.g(), c.b()) } @@ -1130,7 +1130,7 @@ fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { /// id of the sketch point it snapped to. pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, Option)> { let sid = app.session.active_sketch?; - let st = app.session.model.state(); + let st = app.session.world_state(); let ss = st.sketch(sid)?; for h in pick(app, proj, pos) { if let Hit::SketchPoint { id, at, .. } = h { @@ -1362,7 +1362,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { && app.tool.is_none() && app.dialog.is_none() && let Some(Hit::Edge { body, index, .. }) = app.viewport.hover.clone() - && let Some(b) = app.session.model.state().body(&body).cloned() + && let Some(b) = app.session.world_state().body(&body).cloned() { let m = b.mesh(); let items: Vec = m @@ -1383,7 +1383,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // Dragging a sketch point of the active sketch moves it; the solver keeps the constraints. if app.tool.is_none() && app.dialog.is_none() && app.viewport.nav.is_none() && app.session.active_sketch.is_some() { let on_plane = |app: &SolveApp, p: Pos2| { - let ss = app.session.active_sketch.and_then(|sid| app.session.model.state().sketch(sid).cloned())?; + let ss = app.session.active_sketch.and_then(|sid| app.session.world_state().sketch(sid).cloned())?; let (o, d) = proj.ray(p); ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w)) }; @@ -1652,7 +1652,7 @@ fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { /// Hover feedback for the active sketch's points and curves (drawn on top of everything). fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let col = Tokens::get().hover_profile_edge; - let st = app.session.model.state(); + let st = app.session.world_state(); match &app.viewport.hover { Some(Hit::SketchCurve { sketch, id, .. }) => { if let Some(ss) = st.sketch(*sketch) @@ -1678,7 +1678,7 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { /// Sketch points, dimension labels and constraint glyphs of the active sketch. fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let t = Tokens::get(); - let st = app.session.model.state(); + let st = app.session.world_state(); let Some(sid) = app.session.active_sketch else { return }; let Some(ss) = st.sketch(sid) else { return }; let sk = &ss.sketch; diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index 9ff3d5d6..d0c96927 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -51,6 +51,7 @@ scenarios!( s25_autosave_recovery, s27_move_triad, s33_move_x_arrow, + s34_component_move_carries_sketches, ); /// Scenarios written ahead of their fixes or features (the user's open bugs): run with @@ -68,6 +69,5 @@ macro_rules! pending { pending!( s31_sketch_on_face_projects: "user bug: a sketch on a face projects its loops as reference (solvecraft-sketch)", s32_cut_in_active_component: "user bug: a cut in the active component leaves others alone (solvecraft-params)", - s34_component_move_carries_sketches: "user bug: moving a component carries its sketches (solvecraft-repo)", s30_pattern_a_hole: "the dialog sends the hole as a feature; the engine's pattern of a blind hole cuts too little (one hole short)", );