diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index a52580e3..c09a7725 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -244,6 +244,21 @@ impl Document { m } + /// World transform of one placement of `component`: the occurrence `path` from the root + /// (empty for the root itself). `None` when the path no longer leads to that component. + pub fn path_transform(&self, component: u64, path: &[u64]) -> Option { + if path.len() > MAX_PLACEMENT_DEPTH + 1 { + return None; + } + let (mut m, mut c) = (IDENTITY, 0); + for id in path { + let o = self.occurrences.iter().find(|o| o.id == *id && o.parent == c)?; + m = mat_mul(&m, &o.transform); + c = o.component; + } + (c == component).then_some(m) + } + /// Every placement of each component: (component, occurrence path ids, world transform). /// A branch stops past [`MAX_PLACEMENT_DEPTH`] levels, the walk past [`MAX_PLACEMENTS`] placements. pub fn placements(&self) -> Vec<(u64, Vec, Mat)> { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 7e43f219..3a88a021 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -682,6 +682,11 @@ pub enum FeatureKind { /// Join each mirrored body with its original. #[serde(default, skip_serializing_if = "std::ops::Not::not")] combine: bool, + /// Where each of `bodies` is mirrored from: the occurrence path of the placement it was + /// picked at. The body is mirrored where that placement shows it, as the feature's own + /// component sees it. Empty in older designs, which mirror the bodies' own geometry. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + placements: Vec>, }, /// A drilled hole (simple, counterbore or countersink) at a point, into the material. Hole { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index fee89dd2..e626f98b 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1039,7 +1039,9 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 { FeatureKind::Revolve { to, .. } => to.is_some(), _ => false, }; - if to_object { + // A mirror of placed bodies follows where they are placed. + let placed_mirror = matches!(&f.kind, FeatureKind::Mirror { placements, .. } if !placements.is_empty()); + if to_object || placed_mirror { return if doc.occurrences.is_empty() && doc.body_offsets.is_empty() { 0 } else { placements_hash(doc) }; } let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 }; @@ -3741,6 +3743,20 @@ fn mirror_matrix(pl: &Plane) -> Mat { const MAX_INSTANCES: usize = 1000; +/// Where a mirrored body is, in the frame of the mirror's component: through the placement it +/// was picked at and the feature's component frame. `None` when that is the body's own frame +/// (and in older designs, which saved no placement). +fn mirrored_placement(doc: &Document, f: &Feature, mb: &ModelBody, path: Option<&[u64]>) -> Result> { + let Some(path) = path else { return Ok(None) }; + let owner = doc.model_body_component(mb); + let lost = || DocError::Invalid(format!("the placement `{}` was mirrored at no longer exists", mb.name)); + let world = doc.path_transform(owner, path).ok_or_else(lost)?; + let world = doc.model_body_offset(mb).map(|offset| crate::mat_mul(&world, offset)).unwrap_or(world); + let inverse = crate::mat_inverse(&doc.component_transform(f.component)).ok_or_else(lost)?; + let rel = crate::mat_mul(&inverse, &world); + Ok((!crate::is_identity(&rel)).then_some(rel)) +} + /// The placements (column-major matrices) of a pattern's copies, the original excluded. pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind) -> Result> { let (vals, _) = doc.param_values(); @@ -4670,17 +4686,28 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } Ok(()) } - FeatureKind::Mirror { features, plane, bodies, combine } => { + FeatureKind::Mirror { features, plane, bodies, combine, placements } => { let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?; if bodies.is_empty() { return replay(doc, vals, f, st, &crate::plain(features), &[mirror_matrix(&pl)], warning); } let m = mirror_matrix(&pl); let mut output = 0; - for n in bodies { + for (k, n) in bodies.iter().enumerate() { let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; let Some(mb) = st.bodies.get(i).cloned() else { continue }; - let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, m)?, 1); + // A body of a component placed elsewhere is mirrored where it is shown: the copy + // (in this feature's component) from its placement, a joined body in its own frame. + let placed = mirrored_placement(doc, f, &mb, placements.get(k).map(Vec::as_slice))?; + let (copy, own) = match placed { + Some(rel) => { + let inverse = crate::mat_inverse(&rel).ok_or_else(|| DocError::Invalid(format!("the placement of `{n}` is singular")))?; + let copy = crate::mat_mul(&m, &rel); + (copy, crate::mat_mul(&inverse, ©)) + } + None => (m, m), + }; + let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, if *combine { own } else { copy })?, 1); if *combine { let joined = kernel::boolean(&mb.body, &mirrored.body, BoolOp::Union)? .ok_or_else(|| DocError::Invalid("the mirror join produced nothing".into()))?; diff --git a/crates/doc/src/format/pending/mirror-placements.rs b/crates/doc/src/format/pending/mirror-placements.rs new file mode 100644 index 00000000..36432dd6 --- /dev/null +++ b/crates/doc/src/format/pending/mirror-placements.rs @@ -0,0 +1,5 @@ +//! A Mirror of bodies keeps the placement each body was picked at (`placements`: one occurrence +//! path per body), and mirrors the body where that placement shows it, so a body of a moved or +//! inserted component, or of one of its further instances, is mirrored where it is seen. Older +//! designs need no transformation: a Mirror without `placements` mirrors the bodies' own +//! geometry as before; the last release asks for an update instead of mirroring them there. diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index c614a0f9..20becc82 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -1358,6 +1358,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result { let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() }; let plane = plane_param(s, p.get("plane"), cmd)?; let combine = bool_(p, "combine").unwrap_or(false); + let (bodies, placements) = placed_bodies(s, bodies, cmd)?; add_feature( s, p, @@ -1366,10 +1367,31 @@ fn mirror(s: &mut Session, p: &Value) -> Result { plane, bodies: bodies.into_iter().map(BodyName).collect(), combine, + placements, }, ) } +/// Bodies picked where the viewport shows them: each world name ("Body1", or "Body1 (Occ:2)" +/// for a further instance) becomes its component's body name, with the occurrence path of the +/// placement it was picked at. Names stay as they are (and no placement is kept) while every +/// component is in place, or when one of them is not a body. +fn placed_bodies(s: &Session, names: Vec, cmd: &str) -> Result<(Vec, Vec>)> { + if names.is_empty() || !crate::frames::placed(s) { + return Ok((names, Vec::new())); + } + let frames = s.doc.body_frames_with_source(&s.model.state()); + let mut picked = Vec::with_capacity(names.len()); + for name in &names { + let Some((_, own, _, path, _)) = frames.iter().find(|(world, ..)| world == name) else { return Ok((names, Vec::new())) }; + if path.iter().any(|id| s.pending_moves.contains_key(id)) { + return Err(bad(cmd, format!("`{name}` has a position that is not captured yet: Capture Position (component.capture_position) first"))); + } + picked.push((own.clone(), path.clone())); + } + Ok(picked.into_iter().unzip()) +} + /// The other name of an origin plane (`Top` for `XY`, `XY` for `Top`...), if `name` is one. pub(super) fn origin_plane_alias(name: &str) -> Option<&'static str> { match name.trim().to_ascii_uppercase().as_str() { diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index 10fed494..122e1626 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -36,7 +36,8 @@ enum K { /// component frame, whichever component the geometry under it belongs to (a sketch in the /// root on a moved component's face finds the face where it is shown). Here, - /// A plane object for a new sketch or canvas: into the active component's frame. + /// A plane object for a new sketch or canvas (or a mirror's copies): into the active + /// component's frame. HerePlane, } @@ -59,7 +60,10 @@ fn table(id: &str) -> &'static [(&'static str, K)] { "solid.press_pull" => &[("face", Pick), ("edges", Picks)], "solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)], "solid.align" => &[("from", Pick), ("from_face", Pick), ("to", Pick), ("to_face", Pick), ("bodies", Body)], - "solid.mirror" | "solid.split_body" => &[("plane", Plane), ("bodies", Body), ("body", Body)], + // A mirror keeps the placement its bodies were picked at (`cmd::features::placed_bodies`); + // its plane is in the active component, where the copies go. + "solid.mirror" => &[("plane", HerePlane)], + "solid.split_body" => &[("plane", Plane), ("bodies", Body), ("body", Body)], "plastic.boss" | "plastic.snap_fit" | "plastic.rest" => { &[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)] } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index db68167a..98f0b986 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -314,6 +314,7 @@ mod large_step_import; mod loft_cut; mod loft_ring; mod loft_spacing; +mod mirror_placed; mod mirror_planes; #[cfg(not(target_arch = "wasm32"))] mod native_cad_import; diff --git a/crates/engine/src/tests/mirror_placed.rs b/crates/engine/src/tests/mirror_placed.rs new file mode 100644 index 00000000..781fca35 --- /dev/null +++ b/crates/engine/src/tests/mirror_placed.rs @@ -0,0 +1,105 @@ +//! `solid.mirror` of bodies in a placed component (a moved occurrence, a further instance, an +//! inserted design) mirrors each body where it is shown. Before, it mirrored the component's own +//! geometry and put the copy in the active component, so the copy lost the placement. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use super::run; +use crate::*; + +fn world_box(s: &Session, body: &str) -> (Vec3, Vec3) { + let b = s.world_state().body(body).unwrap_or_else(|| panic!("no body `{body}`")).mesh().bounds(); + (b.min, b.max) +} + +/// The box of `body` mirrored in the world YZ plane. +fn mirrored_yz(s: &Session, body: &str) -> (Vec3, Vec3) { + let (lo, hi) = world_box(s, body); + (Vec3::new(-hi.x, lo.y, lo.z), Vec3::new(-lo.x, hi.y, hi.z)) +} + +fn assert_box(got: (Vec3, Vec3), want: (Vec3, Vec3)) { + assert!(got.0.dist(want.0) < 1e-6 && got.1.dist(want.1) < 1e-6, "got {got:?}, want {want:?}"); +} + +/// A component "Arm" holding "ArmBody", its occurrence turned and moved; the root is active. +fn moved_arm(s: &mut Session) -> (u64, u64) { + let c = run(s, "component.create", json!({"name": "Arm"})); + let (component, occurrence) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()); + run(s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 4, "height": 6, "body_name": "ArmBody"})); + run(s, "component.activate", json!({"component": 0})); + run(s, "occurrence.move", json!({"occurrence": occurrence, "axis": [1, 0, 0], "angle": "90 deg", "translate": [30, 50, 0]})); + (component, occurrence) +} + +fn created(r: &Value) -> String { + r["created_bodies"][0].as_str().unwrap().to_string() +} + +#[test] +fn a_moved_components_body_is_mirrored_where_it_is_shown() { + let mut s = Session::default(); + let (_, occurrence) = moved_arm(&mut s); + let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"}))); + assert_box(world_box(&s, ©), mirrored_yz(&s, "ArmBody")); + // The copy follows its source when the occurrence moves again. + run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "space": "world", "translate": [0, 200, 0]})); + assert_box(world_box(&s, ©), mirrored_yz(&s, "ArmBody")); + // It survives a save and reopen. + let t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_box(world_box(&t, ©), mirrored_yz(&t, "ArmBody")); +} + +#[test] +fn a_further_instance_is_mirrored_at_its_own_placement() { + let mut s = Session::default(); + let (component, _) = moved_arm(&mut s); + run(&mut s, "occurrence.copy", json!({"component": component, "translate": [0, 0, 40]})); + let names: Vec = s.world_state().bodies.iter().map(|b| b.name.clone()).collect(); + let instance = names.iter().find(|n| n.starts_with("ArmBody (")).cloned().unwrap(); + let r = run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody", instance], "plane": "YZ"})); + let copies: Vec = r["created_bodies"].as_array().unwrap().iter().map(|n| n.as_str().unwrap().to_string()).collect(); + assert_eq!(copies.len(), 2, "{r}"); + assert_box(world_box(&s, &copies[0]), mirrored_yz(&s, "ArmBody")); + assert_box(world_box(&s, &copies[1]), mirrored_yz(&s, &instance)); +} + +#[test] +fn a_plane_picked_on_a_moved_body_is_a_plane_of_the_active_component() { + let mut s = Session::default(); + moved_arm(&mut s); + // A world plane x = 30 through the moved body: the copy is mirrored in it, as shown. + let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": {"origin": [30, 50, 0], "normal": [1, 0, 0]}}))); + let (lo, hi) = world_box(&s, "ArmBody"); + assert_box(world_box(&s, ©), (Vec3::new(60.0 - hi.x, lo.y, lo.z), Vec3::new(60.0 - lo.x, hi.y, hi.z))); +} + +#[test] +fn a_joined_mirror_of_a_moved_body_stays_in_its_component() { + let mut s = Session::default(); + moved_arm(&mut s); + // A world plane through the body's far end: the joined body doubles along x where it is. + let (lo, hi) = world_box(&s, "ArmBody"); + run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": {"origin": [hi.x, 0, 0], "normal": [1, 0, 0]}, "combine": true})); + let len = hi.x - lo.x; + assert_box(world_box(&s, "ArmBody"), (lo, Vec3::new(hi.x + len, hi.y, hi.z))); +} + +#[test] +fn bodies_in_place_keep_no_placement() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"})); + run(&mut s, "solid.mirror", json!({"bodies": ["Base"], "plane": "YZ"})); + let f = s.doc.features.last().unwrap(); + assert!(matches!(&f.kind, solidcraft_doc::FeatureKind::Mirror { placements, .. } if placements.is_empty()), "{:?}", f.kind); +} + +#[test] +fn a_position_not_captured_yet_is_refused() { + let mut s = Session::default(); + let (_, occurrence) = moved_arm(&mut s); + run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "translate": [5, 0, 0], "capture": false})); + let e = s.execute("solid.mirror", &json!({"bodies": ["ArmBody"], "plane": "YZ"})).unwrap_err().to_string(); + assert!(e.contains("Capture Position"), "{e}"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ff9703e2..b4ef0387 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4921,14 +4921,20 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Mirror { features, plane, bodies, placements, .. } => { let mut d = start("solid.mirror")?; let ids: Vec = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect(); // Replay references stay features, including features that created a body. let as_features = !features.is_empty(); if let Some(inp) = d.inputs.get_mut(0) { if !bodies.is_empty() { - inp.items = bodies.iter().map(|name| Sel::Body { name: name.to_string() }).collect(); + // Each body as the placement it was mirrored at shows it (an instance by its world name). + let own = s.model.state(); + let world = |k: usize, name: &str| match (placements.get(k), own.body(name)) { + (Some(path), Some(b)) => s.doc.world_body_name(&own, name, s.doc.model_body_component(b), path), + _ => name.to_string(), + }; + inp.items = bodies.iter().enumerate().map(|(k, name)| Sel::Body { name: world(k, name) }).collect(); } else if as_features { inp.accept = selection::FEATURES; inp.label = objects_label(inp.accept);