From ed22d663e48d4b736cb4cff0869243206f5c3ffd Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 14 Oct 2026 21:31:10 -0400 Subject: [PATCH] Smart Extrude: direction from the picked side, and a Cut stays a Cut (#1257) (#1261) * Smart Extrude: direction from the picked side, Cut into a body stays a Cut (#1257) - A sketch profile (or a thin extrude's curve) extrudes toward the side it was picked from: from the bottom view it goes down. Body faces still go outward. - The automatic operation is latched per side of the profile: a drag that starts into a body is a Cut and stays one past the far side; a Join does not become a Cut when it reaches another body. Passing through zero decides afresh for the new side. - An explicit operation, Direction or Reverse ends the automatic choices. - auto_operation also looks just past the profile, so a long cut through a body reads as a Cut from the start (the engine's operation: "auto" too). Co-Authored-By: Claude Opus 5.5 * Scenario s07: the XZ profile seen from behind now extrudes toward the camera Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/features.rs | 14 +- crates/engine/src/tests.rs | 3 + crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/dialogs.rs | 58 ++++++- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/smart_extrude.rs | 164 ++++++++++++++++++ crates/ui-egui/src/smart_extrude_ui_tests.rs | 125 +++++++++++++ .../scenarios/s07_components_joint_drive.json | 5 +- 8 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/smart_extrude.rs create mode 100644 crates/ui-egui/src/smart_extrude_ui_tests.rs diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 20141435..5feee761 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -137,9 +137,16 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> { Some((ss.plane.to_world(q), ss.plane.normal())) } +/// How far past the profile [`auto_operation`] looks for material it starts in. +const START_PROBE_MM: f64 = 0.1; + /// What `operation: "auto"` means for an extrude with these parameters: `cut` when it goes /// into a body, otherwise `join` when the active component has a solid. Keeping an /// addition separate requires an explicit `new` operation. +/// +/// "Goes into a body" is decided where the extrude starts: just past the profile in the +/// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far +/// past a body's far side read as a Join (#1257). pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?; let dir = str_(p, "direction").map(str::to_ascii_lowercase); @@ -161,10 +168,13 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { return Some("new"); } let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q)); + // A step into whatever is right in front of the profile, short of the far end. + let near = |d: f64| d.signum() * (d.abs() * 0.5).min(START_PROBE_MM); + let into = |d: f64| inside(base + n * near(d)) || inside(base + n * (d * 0.5)); if symmetric { - return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "join" }); + return Some(if into(d.abs()) || into(-d.abs()) { "cut" } else { "join" }); } - if inside(base + n * (d * 0.5)) { + if into(d) { Some("cut") } else { // A profile can meet material along its side, or only around part of a diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 16183f4f..26f73bd9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1148,6 +1148,9 @@ fn extrude_auto_operation() { assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0) < 1e-6); // Pulled out, it joins. assert_eq!(auto_operation(&s, &json!({"face": [20, 15, 15], "distance": 5})), Some("join")); + // Pushed in far past the bottom (halfway is below the box), it still cuts (#1257). + assert_eq!(auto_operation(&s, &json!({"face": [20, 15, 15], "distance": -50})), Some("cut")); + assert_eq!(auto_operation(&s, &json!({"face": [20, 15, 15], "distance": 50, "direction": "negative"})), Some("cut")); // A sketch on XY under the box: up goes into it (cut), down grows out of it (join), and a // profile beside the box still defaults to Join (New Body remains explicit). run(&mut s, "sketch.create", json!({"plane": "XY"})); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 043534ac..7a3a307b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -159,7 +159,7 @@ fn poly_mid(poly: &[Vec2]) -> Option<(Vec2, Vec2)> { last } -fn profile_plane(app: &SolveApp, sketch: u64) -> Option { +pub(crate) fn profile_plane(app: &SolveApp, sketch: u64) -> Option { let component = app.session.doc.feature(sketch)?.component; let state = app.session.model.state(); let plane = state.sketch(sketch)?.plane; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9346aceb..d72d28e4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -42,6 +42,8 @@ pub enum Kind { /// Into a body: cut; additions: join when a solid exists, otherwise new. /// until the user picks one. auto_op: bool, + /// The direction from the picked side and the latched operation (#1257). + smart: crate::smart_extrude::Smart, /// The other side's distance (two sides). distance2: String, /// Taper angle (empty or zero: none). @@ -439,6 +441,7 @@ impl Dialog { direction: 0, operation: usize::from(has_bodies), auto_op: true, + smart: Default::default(), distance2: "10 mm".into(), taper: "0 deg".into(), taper_present: false, @@ -1797,23 +1800,56 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>) { } } -/// Keep an automatic extrude operation in step with the geometry. +/// Smart Extrude (#1257, see [`crate::smart_extrude`]): a new pick sets the direction from the +/// side it was picked from, and the automatic operation follows the geometry, latched per side. fn auto_operation(app: &SolveApp, d: &mut Dialog) { + let Kind::Extrude { smart, .. } = &d.kind else { return }; + let picked = d.inputs.first().map(|i| i.items.as_slice()).unwrap_or_default(); + if smart.picked != picked { + let new_first = smart.picked.first() != picked.first(); + let picked = picked.to_vec(); + let side_of_pick = if new_first && !smart.manual_dir { picked.first().and_then(|first| picked_side(app, d, first)) } else { None }; + let Kind::Extrude { direction, smart, .. } = &mut d.kind else { return }; + smart.picked = picked; + smart.latch = None; + if *direction < 2 + && let Some(dir) = side_of_pick + { + *direction = dir; + } + } + let Kind::Extrude { direction, distance, .. } = &d.kind else { return }; + let Ok(dist) = app.session.doc.eval(distance, solidcraft_engine::doc::expr::Kind::Length) else { return }; + let side = crate::smart_extrude::side(dist, *direction); if !matches!(d.kind, Kind::Extrude { auto_op: true, .. }) { return; } - let Some(op) = - dialog_commands(app, d).ok().and_then(|c| c.into_iter().next()).and_then(|(_, p)| solidcraft_engine::auto_operation(&app.session, &p)) + let Some(fresh) = dialog_commands(app, d) + .ok() + .and_then(|c| c.into_iter().next()) + .and_then(|(_, p)| solidcraft_engine::auto_operation(&app.session, &p)) + .and_then(|op| OPS.iter().position(|o| *o == op)) else { return; }; - if let Kind::Extrude { operation, .. } = &mut d.kind - && let Some(i) = OPS.iter().position(|o| *o == op) - { - *operation = i; + if let Kind::Extrude { operation, smart, .. } = &mut d.kind { + *operation = crate::smart_extrude::operation(fresh, *operation, side, &mut smart.latch); } } +/// The one-side direction a new extrude takes for its first pick: toward the side of a sketch +/// profile (or curve) the camera looks from; None for picks that keep their own way. +fn picked_side(app: &SolveApp, d: &Dialog, first: &Sel) -> Option { + let normal = match first { + Sel::Profile { sketch, .. } => crate::canvas::profile_plane(app, *sketch)?.normal(), + Sel::SketchCurve { id } => app.session.world_state().sketches.iter().rev().find(|ss| ss.sketch.curve_index(id).is_some())?.plane.normal(), + Sel::Face { .. } => return Some(0), + _ => return None, + }; + let (at, _) = crate::canvas::anchor(app, d)?; + Some(crate::smart_extrude::picked_direction(crate::smart_extrude::view_ray(&app.cam, at), normal, false)) +} + /// Show the active dialog (if any), docked flush to an edge of the viewport just below the axis /// view, sized to its content. The right edge is its place, except while a sketch is open: the /// Sketch Palette keeps the right there, and the dialog takes the left so both stay on screen. @@ -1995,6 +2031,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { direction, operation, auto_op, + smart, distance2, taper, taper_present, @@ -2050,6 +2087,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos); if pos != order.iter().position(|o| o == direction).unwrap_or(0) { *direction = order.get(pos).copied().unwrap_or(0); + // An explicit direction ends the automatic choices (#1257). + smart.manual_dir = true; + *auto_op = false; } ui.end_row(); if *direction < 2 { @@ -2057,6 +2097,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut reverse = *direction == 1; if ui.checkbox(&mut reverse, tr("Reverse Direction")).changed() { *direction = usize::from(reverse); + // An explicit direction ends the automatic choices (#1257). + smart.manual_dir = true; + *auto_op = false; } ui.end_row(); } @@ -4139,6 +4182,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option, + /// The direction was set by hand (or comes from a saved extrude): picks keep it. + pub manual_dir: bool, + /// The automatic operation (an index into the operations) and the side it was chosen for. + pub latch: Option<(usize, i8)>, +} + +/// One-side direction for a new pick: 0 (along the normal) or 1 (reversed). `ray` is the view +/// ray toward the pick; a ray running along the normal looks at the back of the profile, so +/// the extrude comes back toward the camera. Body faces always go outward. +pub fn picked_direction(ray: Vec3, normal: Vec3, body_face: bool) -> usize { + usize::from(!body_face && ray.dot(normal) > 1e-9) +} + +/// Which side of the profile an extrude goes to: +1 along the normal, -1 against it, 0 for +/// none (a zero distance). Symmetric and two-sided extrudes go both ways, so always +1. +pub fn side(distance: f64, direction: usize) -> i8 { + if direction >= 2 { + return 1; + } + let d = if direction == 1 { -distance } else { distance }; + if !d.is_finite() || d.abs() < 1e-9 { + 0 + } else if d > 0.0 { + 1 + } else { + -1 + } +} + +/// The operation to show: the latched one while the extrude stays on the side it was chosen +/// for, otherwise `fresh` (what the geometry says now), which is then latched for this side. +/// `current` stands while the distance is zero. +pub fn operation(fresh: usize, current: usize, side: i8, latch: &mut Option<(usize, i8)>) -> usize { + if side == 0 { + return current; + } + match *latch { + Some((op, s)) if s == side => op, + _ => { + *latch = Some((fresh, side)); + fresh + } + } +} + +/// The view ray at `at`: from the eye for a perspective view, along the view for a parallel one. +pub fn view_ray(cam: &solidcraft_engine::render::Camera, at: Vec3) -> Vec3 { + if cam.fov > 0.0 { (at - cam.eye()).normalized().unwrap_or(-cam.back()) } else { -cam.back() } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NEW: usize = 0; + const JOIN: usize = 1; + const CUT: usize = 2; + + #[test] + fn direction_follows_the_picked_side() { + let up = Vec3::Z; + // From the top view the ray runs down: the extrude goes up (along the normal). + assert_eq!(picked_direction(-Vec3::Z, up, false), 0); + // From the bottom view the ray runs up: the extrude goes down. + assert_eq!(picked_direction(Vec3::Z, up, false), 1); + // Edge on: along the normal, as before. + assert_eq!(picked_direction(Vec3::X, up, false), 0); + // A body's face goes outward from either side. + assert_eq!(picked_direction(Vec3::Z, up, true), 0); + assert_eq!(picked_direction(-Vec3::Z, up, true), 0); + } + + #[test] + fn side_of_the_distance() { + assert_eq!(side(5.0, 0), 1); + assert_eq!(side(-5.0, 0), -1); + assert_eq!(side(5.0, 1), -1); + assert_eq!(side(-5.0, 1), 1); + assert_eq!(side(0.0, 0), 0); + assert_eq!(side(f64::NAN, 0), 0); + assert_eq!(side(-5.0, 2), 1, "symmetric"); + assert_eq!(side(-5.0, 3), 1, "two sides"); + } + + /// A drag into a box that goes on past its far side: the geometry alone would say Join + /// there, the latch keeps the Cut. + #[test] + fn a_cut_stays_a_cut_past_the_far_side() { + let mut latch = None; + let mut op = JOIN; + // Into the body: Cut. + op = operation(CUT, op, -1, &mut latch); + assert_eq!(op, CUT); + // Further, then past the far side, where a fresh look would see no material. + for fresh in [CUT, CUT, JOIN, JOIN] { + op = operation(fresh, op, -1, &mut latch); + assert_eq!(op, CUT); + } + } + + #[test] + fn a_join_does_not_turn_into_a_cut() { + let mut latch = None; + let mut op = operation(JOIN, NEW, 1, &mut latch); + assert_eq!(op, JOIN); + // The tool reaches another body further out. + op = operation(CUT, op, 1, &mut latch); + assert_eq!(op, JOIN); + } + + /// Reversing through zero decides afresh for the new side; back again, afresh again. + #[test] + fn reversing_through_zero_decides_again() { + let mut latch = None; + let mut op = operation(CUT, JOIN, -1, &mut latch); + assert_eq!(op, CUT); + // At zero nothing changes. + op = operation(JOIN, op, 0, &mut latch); + assert_eq!(op, CUT); + assert_eq!(latch, Some((CUT, -1))); + // Out the other side, away from the body: Join, latched there. + op = operation(JOIN, op, 1, &mut latch); + assert_eq!(op, JOIN); + op = operation(CUT, op, 1, &mut latch); + assert_eq!(op, JOIN); + // Back into the body: Cut again. + op = operation(CUT, op, -1, &mut latch); + assert_eq!(op, CUT); + } + + #[test] + fn view_ray_of_both_projections() { + let mut cam = solidcraft_engine::render::Camera { fov: 0.0, ..Default::default() }; + assert!((view_ray(&cam, Vec3::ZERO) + cam.back()).len() < 1e-12); + cam.fov = 0.8; + let at = Vec3::new(10.0, 0.0, 0.0); + let r = view_ray(&cam, at); + assert!((r - (at - cam.eye()).normalized().unwrap()).len() < 1e-12); + } +} diff --git a/crates/ui-egui/src/smart_extrude_ui_tests.rs b/crates/ui-egui/src/smart_extrude_ui_tests.rs new file mode 100644 index 00000000..4c1b4ca7 --- /dev/null +++ b/crates/ui-egui/src/smart_extrude_ui_tests.rs @@ -0,0 +1,125 @@ +//! Smart Extrude in the real window (#1257): the direction follows the side a profile is picked +//! from, a drag into a body cuts and stays a Cut past the far side, and explicit choices win. +use crate::dialogs::{Dialog, Kind}; +use crate::scenario::Harness; +use serde_json::json; + +const JOIN: usize = 1; +const CUT: usize = 2; + +/// The extrude window's direction, operation and whether the operation is still automatic. +fn state(h: &Harness) -> (usize, usize, bool) { + let Some(Dialog { kind: Kind::Extrude { direction, operation, auto_op, .. }, .. }) = &h.app.dialog else { + panic!("extrude dialog: {:?}", h.app.dialog) + }; + (*direction, *operation, *auto_op) +} + +fn bounds_z(h: &Harness) -> (f64, f64) { + let st = h.app.session.world_state(); + let b = st.bodies.last().expect("a body").mesh().bounds(); + (b.min.z, b.max.z) +} + +fn volume(h: &Harness) -> f64 { + let st = h.app.session.world_state(); + st.bodies.iter().map(|b| solidcraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum() +} + +/// A sketch on XY extruded from the top view goes up, and from the bottom view goes down. +#[test] +fn a_profile_picked_from_below_extrudes_down() { + for (view, dir, z) in [("top", 0, (0.0, 10.0)), ("bottom", 1, (-10.0, 0.0))] { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,20]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("select.clear", json!({})).unwrap(); + h.step(&json!({"call":"ui.view","params":{"view":view}})).unwrap(); + h.settle(); + h.step(&json!({"start":"solid.extrude"})).unwrap(); + // Pick the profile in the viewport, as a hand does. + h.step(&json!({"click":{"world":[10,10,0]}})).unwrap(); + h.frames(2); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1, "{view}: the profile is picked"); + assert_eq!(state(&h).0, dir, "{view}: direction"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{view}: OK"); + let (lo, hi) = bounds_z(&h); + assert!((lo - z.0).abs() < 1e-6 && (hi - z.1).abs() < 1e-6, "{view}: the body spans z {lo}..{hi}"); + } +} + +/// A 20 x 20 x 10 box and an extrude from its top: the top face itself (Press Pull), or a +/// 10 x 10 square sketched on it. +fn box_with_top_extrude(face: bool) -> Harness { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":20,"width":20,"height":10,"corner":[0,0,0]})).unwrap(); + if !face { + h.app.run("sketch.create", json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[15,15]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("select.clear", json!({})).unwrap(); + } + h.step(&json!({"call":"ui.view","params":{"view":"home"}})).unwrap(); + h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap(); + h.settle(); + if face { + h.step(&json!({"start":"solid.press_pull"})).unwrap(); + h.step(&json!({"click":{"world":[10,10,10]}})).unwrap(); + } else { + // The new sketch's profile is taken at once. + h.step(&json!({"start":"solid.extrude"})).unwrap(); + } + h.frames(2); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1, "the top is picked"); + h +} + +fn drag_to(h: &mut Harness, z: f64) { + h.step(&json!({"drag":[{"handle":"arrow"},{"world":[10,10,z]}]})).unwrap(); + h.frames(2); +} + +/// Pushed into the box the top cuts; dragged on far past the bottom it stays a Cut (a cut +/// through), for a face (Press Pull) and a profile on it alike. +#[test] +fn a_drag_past_the_far_side_stays_a_cut() { + for face in [true, false] { + let mut h = box_with_top_extrude(face); + assert_eq!(state(&h), (0, JOIN, true), "face {face}: outward from the top: Join"); + drag_to(&mut h, 5.0); + assert_eq!(state(&h), (0, CUT, true), "face {face}: into the box: Cut"); + drag_to(&mut h, -40.0); + assert_eq!(state(&h), (0, CUT, true), "face {face}: far past the bottom: still Cut"); + if face { + continue; + } + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert!((volume(&h) - (4000.0 - 1000.0)).abs() < 1e-6, "a square hole right through: {}", volume(&h)); + } +} + +/// The reverse of a drag: out the other side of zero the choice is made afresh. +#[test] +fn reversing_past_the_start_joins_again() { + let mut h = box_with_top_extrude(true); + drag_to(&mut h, 4.0); + assert_eq!(state(&h).1, CUT); + drag_to(&mut h, 16.0); + assert_eq!(state(&h), (0, JOIN, true), "pulled out: Join"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!((volume(&h) - 20.0 * 20.0 * 16.0).abs() < 1e-6, "{}", volume(&h)); +} + +/// Reverse is an explicit choice: the operation stops following the geometry. +#[test] +fn an_explicit_reverse_keeps_the_operation() { + let mut h = box_with_top_extrude(true); + h.step(&json!({"widget":"Reverse Direction"})).unwrap(); + h.frames(2); + assert_eq!(state(&h), (1, JOIN, false), "reversed into the box, the operation stays as it was"); + drag_to(&mut h, 2.0); + assert_eq!(state(&h), (1, JOIN, false)); +} diff --git a/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json b/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json index ce212e33..8d4e318c 100644 --- a/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json +++ b/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json @@ -139,7 +139,10 @@ "start": "solid.extrude" }, { - "text": "20" + "note": "The iso view sees the XZ sketch from behind (its normal is +Y), so the extrude comes toward the camera (-Y). -20 takes it back along +Y, where the joint below expects it." + }, + { + "text": "-20" }, { "key": "Enter"