From cf9d7772f1d01a3a40fa2d48adb9c1848489ac26 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:41:59 +0200 Subject: [PATCH] To / From Object finds a target in another, moved component (#993) Fixes #928. Extrude To / From Object and Revolve To Object now resolve their target in the feature's own component frame, so a face in another component whose occurrence is moved is found where it is shown instead of the extrude stopping at a flat plane through the picked point. The point is named after the nearest face, a named point keeps its named face when another component touches it there, and such features re-evaluate when a placement changes, so they follow the target when it moves later. Co-authored-by: SychPL --- crates/doc/src/eval.rs | 128 +++++++++++--- crates/doc/src/lib.rs | 4 +- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/features.rs | 2 +- crates/engine/src/tests/component_frames.rs | 181 ++++++++++++++++++++ 5 files changed, 289 insertions(+), 28 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index c554605d..9146d40a 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -897,6 +897,15 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 { } return hash.finish(); } + // To / From Object finds its target where that target's component is placed (#928). + let to_object = match &f.kind { + FeatureKind::Extrude { extent, .. } => extent.to.is_some() || extent.from.is_some(), + FeatureKind::Revolve { to, .. } => to.is_some(), + _ => false, + }; + if to_object { + 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 }; let anchor = crate::face_anchor::plane_face_anchor(plane); if (doc.occurrences.is_empty() && (anchor.is_none() || doc.body_offsets.is_empty())) @@ -904,6 +913,11 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 { { return 0; } + placements_hash(doc) +} + +/// Every occurrence's placement and every body offset. +fn placements_hash(doc: &Document) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); for o in &doc.occurrences { o.id.hash(&mut h); @@ -1105,17 +1119,18 @@ impl Model { FeatureKind::Hole { direction, points: None, .. } => direction.normalized(), _ => None, }; + let fst = face_point_state(doc, &state, &f.kind, f.component); for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() { if n.is_empty() { continue; } - match crate::naming::locate_face(&state, n, *p) { + match crate::naming::locate_face(&fst, n, *p) { Some(hit) => { if let Some(note) = hit.note(n) { notes.push(note); } match (k, hole_axis) { - (0, Some(axis)) => match hit.along(&state, *p, axis) { + (0, Some(axis)) => match hit.along(&fst, *p, axis) { Some(q) => *p = q, // The place still lies on the face (the axis may run along it). None if hit.on_point => {} @@ -2489,15 +2504,22 @@ fn feature_tools_with_constructor( )?; let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); // From / To Object: where the face or point lies along the normal, measured over the - // middle of the profiles. + // middle of the profiles. The points are in this feature's component frame, so the + // model is seen from there: a target in another, moved component where it is shown. let n = ss.plane.normal(); + let tst = + if extent.to.is_some() || extent.from.is_some() { state_in_frame(doc, st, f.component) } else { std::borrow::Cow::Borrowed(st) }; + // The face names follow `face_points`: To, then From. + let name = |k: usize| f.face_names.get(k).map(String::as_str); + let (to_name, from_name) = if extent.to.is_some() { (name(0), name(1)) } else { (None, name(0)) }; + let to_face = extent.to.and_then(|p| object_face(&tst, to_name, p)); let centre = { let pts: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); let c = pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64); ss.plane.to_world(c) }; let from = match extent.from { - Some(p) => object_height(st, &ss.plane, centre, p), + Some(p) => height_at(&tst, object_face(&tst, from_name, p), &ss.plane, centre, p), None => 0.0, }; let d = match extent.to { @@ -2506,7 +2528,7 @@ fn feature_tools_with_constructor( Some(e) => val(vals, e, Kind::Length)?, None => 0.0, }; - let h = object_height(st, &ss.plane, centre, p); + let h = height_at(&tst, to_face, &ss.plane, centre, p); let start = from + extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0); // Past the face by the offset, in the direction travelled. let to = h + if h >= start { off } else { -off }; @@ -2550,9 +2572,9 @@ fn feature_tools_with_constructor( let (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) }; // To a curved (or slanted) face: run right through the target body, then keep what // lies before its surface (the tool less the body, the piece at the start). - let curved = extent.to.and_then(|p| curved_target(st, n, p)); + let curved = curved_face(&tst, to_face, Some(n)); if let Some(bi) = curved - && let Some(tb) = st.bodies.get(bi) + && let Some(tb) = tst.bodies.get(bi) { let m = tb.mesh(); let hs: Vec = m.positions.iter().map(|q| (*q - ss.plane.origin).dot(n) - off).collect(); @@ -2604,7 +2626,7 @@ fn feature_tools_with_constructor( tools = vec![Traced::step(one, &tools.iter().collect::>())]; } } - if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) { + if let Some(tb) = curved.and_then(|bi| tst.bodies.get(bi)) { let start = if hi > 0.0 { lo + off } else { hi + off }; tools = trim_to_body(tools, tb, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?; } @@ -2662,13 +2684,15 @@ fn feature_tools_with_constructor( )?; let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let (o, d) = revolve_axis(&ss, axis)?; + // To Object is in this feature's component frame: the model as seen from there. + let tst = if to.is_some() { state_in_frame(doc, st, f.component) } else { std::borrow::Cow::Borrowed(st) }; let angles = if to.is_some() { (0.0, None) } else { (val(vals, angle, Kind::Angle)?, angle2.as_ref().map(|a| val(vals, a, Kind::Angle)).transpose()?) }; let extent = revolve_extent_with_convention( - st, + &tst, &ss.plane, ®ions, (ss.plane.to_world(o), ss.plane.dir_to_world(d)), @@ -2683,7 +2707,7 @@ fn feature_tools_with_constructor( .map(|r| { if *convention == crate::RevolveConvention::SketchNormal && to.is_some() { revolve_extent_with_convention( - st, + &tst, &ss.plane, std::slice::from_ref(r), (ss.plane.to_world(o), ss.plane.dir_to_world(d)), @@ -2724,8 +2748,8 @@ fn feature_tools_with_constructor( .collect::>>()?; // To a curved face: turn past the whole target body, then cut back to its surface // (the piece that starts at the profile). - let curved = to.and_then(|p| curved_target_any(st, p)); - let curved_body = curved.and_then(|bi| st.bodies.get(bi)); + let curved = curved_face(&tst, to.and_then(|p| object_face(&tst, f.face_names.first().map(String::as_str), p)), None); + let curved_body = curved.and_then(|bi| tst.bodies.get(bi)); // Each region's body: its sides named by the sketch curves, its caps start and end. let tool = |r: &Region2, body: Body, built: Option<&Body>| crate::lineage::ProfileRoles::swept(Some(&ss), Some(r)).tool(body, built); let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result> { @@ -3437,9 +3461,14 @@ mod surface; /// there if it is planar and parallel (its plane), else where a ray from `centre` along the /// normal meets the body there, else the point itself. pub fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 { + height_at(st, face_at(st, p), plane, centre, p) +} + +/// [`object_height`] of the point `p` on `face` (`(body index, face index)`, see [`object_face`]). +fn height_at(st: &ModelState, face: Option<(usize, usize)>, plane: &Plane, centre: Vec3, p: Vec3) -> f64 { let n = plane.normal(); let h = |q: Vec3| (q - plane.origin).dot(n); - let Some((bi, fi)) = face_at(st, p) else { return h(p) }; + let Some((bi, fi)) = face else { return h(p) }; let Some(b) = st.bodies.get(bi) else { return h(p) }; let tol = (b.body.size() * 1e-3).max(1e-3); let Ok(faces) = b.body.faces(tol) else { return h(p) }; @@ -3477,24 +3506,37 @@ fn face_at(st: &ModelState, p: Vec3) -> Option<(usize, usize)> { .map(|(_, bi, fi)| (bi, fi)) } -/// The body at `p` when the face there is not a plane square to `n` (To Object must then -/// follow the face's shape). -fn curved_target(st: &ModelState, n: Vec3, p: Vec3) -> Option { - let (bi, fi) = face_at(st, p)?; +/// The face a To / From Object point stands for, `(body index, face index)`: the face its +/// stored `name` finds when the point lies on it (evaluation has moved the point onto its named +/// face), else the nearest face at the point. The name settles a tie: in an assembly a part of +/// another component may rest on the target and touch it at the point. +fn object_face(st: &ModelState, name: Option<&str>, p: Vec3) -> Option<(usize, usize)> { + if let Some(n) = name.filter(|n| !n.is_empty()) + && let Some(hit) = crate::naming::locate_face(st, n, p) + && hit.on_point + { + return Some((hit.body, hit.face)); + } + face_at(st, p) +} + +/// The body of `face` when that face is not a plane (with `n`: not a plane square to `n`), so To +/// Object must follow its shape. +fn curved_face(st: &ModelState, face: Option<(usize, usize)>, n: Option) -> Option { + let (bi, fi) = face?; let b = st.bodies.get(bi)?; let tol = (b.body.size() * 1e-3).max(1e-3); - let planar = - b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).and_then(|f| f.plane_normal).is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6); + let normal = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).map(|f| f.plane_normal); + let planar = match n { + Some(n) => normal.flatten().is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6), + None => normal.is_some_and(|fnrm| fnrm.is_some()), + }; (!planar).then_some(bi) } /// The body at `p` when the face there is not planar. fn curved_target_any(st: &ModelState, p: Vec3) -> Option { - let (bi, fi) = face_at(st, p)?; - let b = st.bodies.get(bi)?; - let tol = (b.body.size() * 1e-3).max(1e-3); - let planar = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).is_some_and(|f| f.plane_normal.is_some()); - (!planar).then_some(bi) + curved_face(st, face_at(st, p), None) } /// Tools run through `target`, cut back to its surface: each tool less the body, keeping the @@ -4427,6 +4469,44 @@ pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std: state_in_frame_at(doc, st, comp, doc.component_transform(comp)) } +/// The model a feature's face points (`FeatureKind::face_points`) are found in. Extrude's To / +/// From and Revolve's To place geometry, so they are kept in the feature's own component frame +/// (`comp`): the model as that component sees it, a target in another, moved component where it +/// is shown (#928). Other face points are picks, kept in the frame of the body they lie on: the +/// model itself. +pub fn face_point_state<'a>(doc: &Document, st: &'a ModelState, kind: &FeatureKind, comp: u64) -> std::borrow::Cow<'a, ModelState> { + match kind { + FeatureKind::Extrude { .. } | FeatureKind::Revolve { .. } => state_in_frame(doc, st, comp), + _ => std::borrow::Cow::Borrowed(st), + } +} + +/// The names of the faces a feature's face points lie on (empty where none), found in +/// [`face_point_state`]. A To / From point is named after the nearest face over every body, the +/// one `object_height` resolves it to, not the first body with a face within tolerance: a part +/// resting a hair from the picked face would otherwise take its name. +pub fn face_point_names(doc: &Document, st: &ModelState, kind: &FeatureKind, comp: u64) -> Vec { + let pts = kind.face_points(); + if !matches!(kind, FeatureKind::Extrude { .. } | FeatureKind::Revolve { .. }) { + return crate::naming::face_names_at(st, &pts); + } + let fst = face_point_state(doc, st, kind, comp); + pts.iter() + .map(|p| { + fst.bodies + .iter() + .filter(|b| !b.body.is_mesh()) + .filter_map(|b| { + let (d, fi) = crate::appearance::nearest_face(b, *p)?; + (d <= (b.body.size() * 1e-3).max(1e-3)).then_some((d, b, fi)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .and_then(|(_, b, fi)| crate::naming::face_names(b).get(fi).cloned()) + .unwrap_or_default() + }) + .collect() +} + /// 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; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 75fa8078..eafc1dd3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -47,8 +47,8 @@ pub use assembly::{ pub use document::*; pub use eval::{ EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, edge_refs, - hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, - sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state, + face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent, + revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solidcraft_kernel as kernel; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 71a3dbf7..b9f3c2e8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -667,7 +667,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { } // New face points: their faces' names now. if nf.kind.face_points() != f.kind.face_points() { - nf.face_names = solidcraft_doc::naming::face_names_at(&s.model.state_before(id), &nf.kind.face_points()); + nf.face_names = solidcraft_doc::face_point_names(&s.doc, &s.model.state_before(id), &nf.kind, nf.component); } if let Some(slot) = s.doc_mut().feature_mut(id) { *slot = nf; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 11ba1625..1b52c2a5 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -418,7 +418,7 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> } else if pts.is_empty() { Vec::new() } else { - solidcraft_doc::naming::face_names_at(&s.model.state(), &pts) + solidcraft_doc::face_point_names(&s.doc, &s.model.state(), &kind, s.active_component) } }; // Primitives have no `targets` of their own: their `targets` are the bodies taking part (#259). diff --git a/crates/engine/src/tests/component_frames.rs b/crates/engine/src/tests/component_frames.rs index 2bd2ffb2..20514c2f 100644 --- a/crates/engine/src/tests/component_frames.rs +++ b/crates/engine/src/tests/component_frames.rs @@ -108,3 +108,184 @@ fn extrude_to_a_face_in_a_moved_component() { let v = run(&mut s, "inspect.measure", json!({"bodies": ["Jaw"]}))["total"]["volume_mm3"].as_f64().unwrap(); assert!(rel(v, 4000.0 + 10.0 * 2.0 * 15.0) < 1e-6, "{v}"); } + +/// Component `Target` with a ramp (top face z = -20 + y/2 over x -10..10, y -20..20, in its own +/// frame), its occurrence moved by `moves`, then a new component `Post`, active, with a 6 x 6 mm +/// sketch `Pad` centred at the origin (#928). +fn ramp_and_pad(s: &mut Session, moves: &[serde_json::Value]) { + run(s, "component.create", json!({"name": "Target"})); + run(s, "sketch.create", json!({"plane": "YZ", "name": "Wedge"})); + run(s, "sketch.line", json!({"points": [[-20, -30], [20, -30], [20, -10], [-20, -30]]})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"sketch": "Wedge", "distance": "20 mm", "direction": "symmetric", "body_name": "Ramp"})); + run(s, "component.activate", json!({"component": "root"})); + for m in moves { + run(s, "occurrence.move", m.clone()); + } + run(s, "component.create", json!({"name": "Post"})); + run(s, "sketch.create", json!({"plane": "XY", "name": "Pad"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [-3, -3], "p1": [3, 3]})); + run(s, "sketch.finish", json!({})); +} + +fn leg_volume(s: &mut Session) -> f64 { + run(s, "inspect.measure", json!({"bodies": ["Leg"]}))["total"]["volume_mm3"].as_f64().unwrap() +} + +/// To Object on a slanted face of a body in another component whose occurrence is moved: the +/// extrude ends at the face where it is shown, not where the component's own frame has it +/// (#928: the pad ran 20 mm deep, as if the ramp had not moved). +#[test] +fn extrude_to_a_slanted_face_in_another_moved_component() { + // Moved 5 mm along +y: the face is z = -20 + (y - 5)/2, 22.5 mm below the pad on average. + let mut s = Session::default(); + ramp_and_pad(&mut s, &[json!({"occurrence": "Target:1", "space": "world", "translate": [0, 5, 0]})]); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Pad", "distance": "1 mm", "direction": "negative", "to": [0, 5, -20], "operation": "new", "body_name": "Leg"}), + ); + let v = leg_volume(&mut s); + assert!(rel(v, 36.0 * 22.5) < 1e-6, "{v}"); + // Turned a quarter turn about Z and moved 4 mm along +x: the face is z = -18 - x/2. + let mut s = Session::default(); + ramp_and_pad( + &mut s, + &[json!({"occurrence": "Target:1", "space": "world", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0], "translate": [4, 0, 0]})], + ); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Pad", "distance": "1 mm", "direction": "negative", "to": [0, 0, -18], "operation": "new", "body_name": "Leg"}), + ); + let v = leg_volume(&mut s); + assert!(rel(v, 36.0 * 18.0) < 1e-6, "{v}"); +} + +/// From Object on a slanted face in another, moved component: the extrude starts where the +/// pad's middle meets the face as shown (z -22.5), not where it is in the component's frame. +#[test] +fn extrude_from_a_slanted_face_in_another_moved_component() { + let mut s = Session::default(); + ramp_and_pad(&mut s, &[json!({"occurrence": "Target:1", "space": "world", "translate": [0, 5, 0]})]); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Pad", "from": [0, 5, -20], "distance": 4, "direction": "negative", "operation": "new", "body_name": "Leg"}), + ); + let b = s.world_state().body("Leg").unwrap().mesh().bounds(); + assert!((b.max.z + 22.5).abs() < 1e-6 && (b.min.z + 26.5).abs() < 1e-6, "{b:?}"); +} + +/// The To face in another component is kept by name, as one in the same component is: when that +/// component's occurrence moves on, the extrude follows the face. +#[test] +fn extrude_to_a_face_in_another_component_follows_it() { + let mut s = Session::default(); + ramp_and_pad(&mut s, &[json!({"occurrence": "Target:1", "space": "world", "translate": [0, 5, 0]})]); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Pad", "distance": "1 mm", "direction": "negative", "to": [0, 5, -20], "operation": "new", "body_name": "Leg"}), + ); + let names = s.doc.features.last().map(|f| f.face_names.clone()).unwrap_or_default(); + assert!(names.first().is_some_and(|n| !n.is_empty()), "the To face is stored by name: {names:?}"); + // Another 10 mm along +y: the face is z = -20 + (y - 15)/2, 27.5 mm below the pad on average. + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "occurrence.move", json!({"occurrence": "Target:1", "space": "world", "translate": [0, 10, 0]})); + let v = leg_volume(&mut s); + assert!(rel(v, 36.0 * 27.5) < 1e-6, "{v}"); +} + +/// Revolve To a curved face of a body in another component whose occurrence is turned: the +/// sweep is cut back to the post where it is shown (here a quarter of a turn earlier, at 45°). +#[test] +fn revolve_to_a_cylinder_in_another_moved_component() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Pin"})); + run(&mut s, "solid.cylinder", json!({"base": [0, 14, -5], "radius": 4, "height": 15, "body_name": "PostBody"})); + run(&mut s, "component.activate", json!({"component": "root"})); + // From 90° round to 45° about Z: the post's axis is at (14 sin 45°, 14 cos 45°). + run(&mut s, "occurrence.move", json!({"occurrence": "Pin:1", "space": "world", "axis": [0, 0, 1], "angle": "-45 deg", "origin": [0, 0, 0]})); + run(&mut s, "sketch.create", json!({"name": "Profile", "plane": "XZ"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [15, 5]})); + run(&mut s, "sketch.finish", json!({})); + let (c, sn) = ((PI / 4.0).cos(), (PI / 4.0).sin()); + // The post's side nearest the profile: its axis plus 4 mm turned the same way. + let to = [14.0 * sn + 4.0 * c, 14.0 * c - 4.0 * sn, 2.0]; + run( + &mut s, + "solid.revolve", + json!({"sketch": "Profile", "axis": {"origin": [0, 0, 0], "dir": [0, 0, 1]}, "to": to, "operation": "new", "body_name": "Arc"}), + ); + let world = s.world_state(); + let arc = &world.body("Arc").unwrap().body; + let post = &world.body("PostBody").unwrap().body; + let overlap = solidcraft_kernel::boolean(arc, post, solidcraft_kernel::BoolOp::Intersect).unwrap(); + assert!(overlap.is_none_or(|b| solidcraft_kernel::measure(&b).unwrap().volume < 1e-3), "the arc runs into the post"); + // Past the picked point's angle (29°) on the inner side, where the post leaves room. + let at = |r: f64, deg: f64| Vec3::new(r * deg.to_radians().cos(), r * deg.to_radians().sin(), -2.5); + assert!(arc.contains(at(10.2, 35.0)), "the arc is not cut back to the post's surface"); +} + +/// A To Object pick is named after the face it is on, the nearest one, even when a face of a +/// body earlier in the model lies within its naming tolerance: here a big shim of another +/// component 0.02 mm under the floor's top face. Naming the shim's face would move the point +/// onto it and end the extrude 0.02 mm too deep. +#[test] +fn extrude_to_names_the_face_it_is_on() { + for moved in [false, true] { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Other"})); + let z = if moved { -40.02 + 10.0 } else { -40.02 }; + run(&mut s, "solid.box", json!({"corner": [-50, -50, z], "length": 100, "width": 100, "height": 20, "body_name": "Shim"})); + run(&mut s, "component.activate", json!({"component": "root"})); + if moved { + run(&mut s, "occurrence.move", json!({"occurrence": "Other:1", "space": "world", "translate": [0, 0, -10]})); + } + run(&mut s, "component.create", json!({"name": "Post"})); + run(&mut s, "solid.box", json!({"corner": [-10, -10, -30], "length": 20, "width": 20, "height": 10, "body_name": "Floor"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Pad"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-3, -3], "p1": [3, 3]})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Pad", "distance": "1 mm", "direction": "negative", "to": [5, 5, -20], "operation": "new", "body_name": "Leg"}), + ); + let b = s.world_state().body("Leg").unwrap().mesh().bounds(); + assert!((b.min.z + 20.0).abs() < 1e-9 && b.max.z.abs() < 1e-9, "moved {moved}: {b:?}"); + } +} + +/// A named To face settles which face the extrude ends at: when a part of another component is +/// later placed against the target, its face through the stored point is as near as the named +/// one, and it comes earlier in the model, but the extrude still follows the slanted face. +#[test] +fn extrude_to_a_named_face_keeps_it_when_another_part_touches_it() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Other"})); + // A plate whose top face is z = 50, out of the way for now. + run(&mut s, "solid.box", json!({"corner": [-50, -50, 30], "length": 100, "width": 100, "height": 20, "body_name": "Plate"})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Post"})); + // The ramp in the extrude's own component: top face z = -20 + y/2. + run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Wedge"})); + run(&mut s, "sketch.line", json!({"points": [[-20, -30], [20, -30], [20, -10], [-20, -30]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Wedge", "distance": "20 mm", "direction": "symmetric", "body_name": "Ramp"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Pad"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-3, -3], "p1": [3, 3]})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "Pad", "distance": "1 mm", "direction": "negative", "to": [0, 2, -19], "operation": "new", "body_name": "Leg"}), + ); + assert!(rel(leg_volume(&mut s), 36.0 * 20.0) < 1e-6, "{}", leg_volume(&mut s)); + // The plate lowered so its top face runs through the To point (z = -19). + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "occurrence.move", json!({"occurrence": "Other:1", "space": "world", "translate": [0, 0, -69]})); + let v = leg_volume(&mut s); + assert!(rel(v, 36.0 * 20.0) < 1e-6, "the extrude ended at the plate: {v}"); +}