Files
OneCAD/crates/ui-egui/src/smart_extrude_ui_tests.rs
T
bherbruckandClaude Opus 5.5 ed22d663e4 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>
2026-10-14 21:31:10 -04:00

126 lines
5.3 KiB
Rust

//! 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));
}