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 <noreply@anthropic.com>

* Scenario s07: the XZ profile seen from behind now extrudes toward the camera

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-14 21:31:10 -04:00
1 parent d73cd8a177
commit ed22d663e4
8 files changed
+363 -11

No files matched your search

+51 -7
View File
@@ -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<usize> {
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<usize>) -> Option<Dia
},
operation: op_index(operation),
auto_op: false,
smart: crate::smart_extrude::Smart { manual_dir: true, ..Default::default() },
distance2: extent.distance2.clone().unwrap_or_else(|| "10 mm".into()),
taper: extent.taper.clone().unwrap_or_else(|| "0 deg".into()),
taper_present: extent.taper.is_some(),