From b34d45233dba677ebf01433a57920251915346f2 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 14 Oct 2026 02:10:09 -0400 Subject: [PATCH] Holes on round faces from the app, and Flip for holes at sketch points (#980) * Holes on round faces from the app, one hole per pick, and Flip for holes at sketch points (#967) The Hole dialog takes any face: a hole on a round face drills against the face's normal where it was picked, and its arrow shows that. A round face split at its seam is picked whole, but makes one hole where it was clicked. Holes at sketch points get Flip (saved as flip in the hole's points, under format 18), in the command and in the dialog. Co-Authored-By: Claude Opus 5.5 * Holes at sketch points show their arrow: into the part, or away with Flip (#967) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/doc/src/document.rs | 3 + crates/doc/src/eval.rs | 12 ++ crates/doc/src/format.rs | 3 + crates/doc/src/lib.rs | 2 +- crates/engine/src/cmd/features.rs | 5 +- crates/engine/src/hole_matrix_tests.rs | 21 +++ crates/ui-egui/src/canvas.rs | 25 +++- crates/ui-egui/src/dialogs.rs | 43 +++++- crates/ui-egui/src/hole_direction_ui_tests.rs | 135 ++++++++++++++++++ crates/ui-egui/src/hole_placement_ui_tests.rs | 6 +- crates/ui-egui/src/lib.rs | 2 + 11 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/hole_direction_ui_tests.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index ab74d864..a6985e9f 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1131,6 +1131,9 @@ pub struct MeshData { pub struct SketchPoints { pub sketch: u64, pub ids: Vec, + /// Drill the other way: away from the side the holes would go into by themselves. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub flip: bool, } /// A component: a node of the design tree that owns sketches, features and bodies. The root diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 487a31d3..d4240c7d 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2050,6 +2050,16 @@ fn hole_reaches(st: &ModelState, p: Vec3, d: Vec3, ahead: bool) -> bool { /// from the plane's normal) if any spot touches material that way, else `+n` if any spot touches /// material that way. With no touching on either side it goes by the nearest body ahead: `-n` if /// any spot has a body ahead that way, else `+n` if any has, else `-n`. +/// The way holes at these points of a sketch drill (see [`sketch_hole_dir`]), flipped or not. +/// None when the sketch or a point is not there. +pub fn sketch_hole_direction(st: &ModelState, sketch: u64, ids: &[String], flip: bool) -> Option { + let ss = st.sketch(sketch)?; + let spots: Vec = + ids.iter().map(|id| ss.sketch.points.iter().find(|p| &p.id == id).map(|p| ss.plane.to_world(p.pos))).collect::>()?; + let dir = sketch_hole_dir(st, &spots, ss.plane.normal()); + Some(if flip { -dir } else { dir }) +} + fn sketch_hole_dir(st: &ModelState, spots: &[Vec3], n: Vec3) -> Vec3 { let down = -n; let any = |d: Vec3, ahead: bool| spots.iter().any(|p| hole_reaches(st, *p, d, ahead)); @@ -2077,6 +2087,7 @@ fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { v.push(ss.plane.to_world(p.pos)); } let dir = sketch_hole_dir(st, &v, ss.plane.normal()); + let dir = if sp.flip { -dir } else { dir }; (v, dir) } None => { @@ -2123,6 +2134,7 @@ fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { let v: Vec = sp.ids.iter().filter_map(|id| ss.sketch.points.iter().find(|p| &p.id == id)).map(|p| ss.plane.to_world(p.pos)).take(10_000).collect(); let dir = sketch_hole_dir(st, &v, ss.plane.normal()); + let dir = if sp.flip { -dir } else { dir }; Ok((v, dir)) } None => { diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 236ddd3f..eed718f5 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -129,6 +129,9 @@ //! taken from another face whose plane passed near the point, such as the far side of a thin //! plate or a face of another body at the same height) is mirrored into that face when the //! design is evaluated. Holes whose direction enters their face build as before. +//! Holes at sketch points can be flipped (`flip: true` in the hole's `points`): they drill away +//! from the side they would go into by themselves. Holes without it save as before; 0.9.0 would +//! drop the flip and drill the other way. //! - 19: editable associative offset-distance dimensions. Older offsets retain their saved //! fixed distances; migration does not invent dimensions or change geometry. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 7552f85b..75fa8078 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -48,7 +48,7 @@ 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, - state_in_frame, state_in_frame_at, through_all_distance, world_state, + 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/features.rs b/crates/engine/src/cmd/features.rs index cedf5836..940116da 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -51,7 +51,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("hole") .key("H") - .params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"), + .params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"), CommandSpec::new("solid.pipe", "Pipe", pipe) .at("SOLID", "CREATE") .icon("pipe") @@ -1356,7 +1356,8 @@ fn hole(s: &mut Session, p: &Value) -> Result { let n = ss.plane.normal(); let first = ids.first().and_then(|id| ss.sketch.points.iter().find(|q| &q.id == id)).map(|q| ss.plane.to_world(q.pos)).unwrap_or_default(); - Some((solidcraft_doc::SketchPoints { sketch, ids }, first, -n)) + let flip = p.get("flip").and_then(Value::as_bool).unwrap_or(false); + Some((solidcraft_doc::SketchPoints { sketch, ids, flip }, first, -n)) } _ => None, }; diff --git a/crates/engine/src/hole_matrix_tests.rs b/crates/engine/src/hole_matrix_tests.rs index c16f8eee..610fbddc 100644 --- a/crates/engine/src/hole_matrix_tests.rs +++ b/crates/engine/src/hole_matrix_tests.rs @@ -506,6 +506,27 @@ fn cases() -> Vec { vec![spot([10.0, 10.0, 0.0], UP, 15.0, Some(15.0))], ) .faces(2), + // Inside the part the holes go down from the top; Flip drills up from the bottom. + Case::new( + "points/mid-plane/blind", + with(block(), points(lifted(10.0), &[[10.0, 10.0]])), + hole(json!({"sketch": "P", "points": ["q0"], "depth": 5})), + cyl(r, 5.0), + r, + vec![spot([10.0, 10.0, 20.0], DOWN, 5.0, Some(5.0))], + ) + .faces(2), + Case::new( + "points/mid-plane/flip", + with(block(), points(lifted(10.0), &[[10.0, 10.0]])), + hole(json!({"sketch": "P", "points": ["q0"], "depth": 5, "flip": true})), + cyl(r, 5.0), + r, + vec![spot([10.0, 10.0, 0.0], UP, 5.0, Some(5.0))], + ) + .faces(2) + // Saved and opened again, it still drills up. + .then(Vec::new(), cyl(r, 5.0)), Case::new( "points/xy-over-part-below-xy/blind", with( diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c316f816..04fd2b34 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -90,7 +90,30 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? }; (at + n * (from + offset), Some(n)) } - Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), + // A hole on a curved face drills against the face's normal where it was picked. + Kind::Hole { .. } if matches!(first, Sel::Face { .. }) => { + (at, normal.or_else(|| solidcraft_engine::doc::hole_direction_at(&st, at).map(|d| -d))) + } + // Holes at sketch points go into the part the sketch touches (or away, flipped): the + // arrow shows which way. + Kind::Hole { .. } => { + let mut by_sketch: Vec<(u64, Vec)> = Vec::new(); + for x in &d.inputs.first()?.items { + if let Sel::SketchPoint { id } = x + && let Some((sk, pid)) = id.split_once(':') + && let Ok(sk) = sk.parse::() + { + match by_sketch.iter_mut().find(|(s, _)| *s == sk) { + Some((_, v)) => v.push(pid.to_string()), + None => by_sketch.push((sk, vec![pid.to_string()])), + } + } + } + let flip = d.extra.get("flip").and_then(serde_json::Value::as_bool).unwrap_or(false); + let dir = by_sketch.first().and_then(|(sk, ids)| solidcraft_engine::doc::sketch_hole_direction(&st, *sk, ids, flip)); + (at, normal.or(dir.map(|d| -d))) + } + Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), // Fillet radii, chamfer distances and shell thickness grow into the body. Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)), Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c7377aef..01efdb67 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -255,7 +255,8 @@ pub enum Kind { /// The Single hole placement's input: places on planar faces. A pick on or near a sketch point /// that lies on the face (a circle's centre) is taken at that point, as Multiple picks it. -const HOLE_FACES: Accept = PLANAR_FACES | selection::POINTS; +/// Holes go on any face (flat or curved; the engine drills against the face's normal there). +const HOLE_FACES: Accept = PLANAR_FACES | FACES | selection::POINTS; /// The rest of the Hole dialog: placement, extents, tap type and drill point. #[derive(Clone, Debug, PartialEq)] @@ -2402,6 +2403,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { row_label(ui, "All Sketch Points"); extra_check(ui, &mut d.extra, "all_points"); ui.end_row(); + // The holes go into the part the sketch touches; Flip drills the + // other way. + row_label(ui, "Flip"); + extra_check(ui, &mut d.extra, "flip"); + ui.end_row(); } row_label(ui, "Hole Type"); combo(ui, "hole_kind", &HOLE_LABELS, kind); @@ -3506,11 +3512,29 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } } let all_points = d.extra.get("all_points").and_then(Value::as_bool).unwrap_or(false); + let flip = d.extra.get("flip").and_then(Value::as_bool).unwrap_or(false); places.extend( - by_sketch.into_iter().map(|(sk, ids)| if all_points { json!({ "sketch": sk }) } else { json!({"sketch": sk, "points": ids}) }), + by_sketch + .into_iter() + .map(|(sk, ids)| if all_points { json!({ "sketch": sk }) } else { json!({"sketch": sk, "points": ids}) }) + .map(|mut place| { + if flip { + place["flip"] = json!(true); + } + place + }), ); } else { - places.extend(face_points(0).into_iter().map(|p| json!({ "position": p }))); + // One hole per pick: a curved surface split at its seam is picked whole (both + // halves, the clicked one first), but the hole goes where it was clicked only. + let mut taken: Vec = Vec::new(); + for x in sels(d, 0) { + if !matches!(x, Sel::Face { .. }) || taken.iter().any(|t| same_face(t, x)) { + continue; + } + taken.extend(crate::viewport::grouped_selection(s, x)); + places.extend(face_point(x).map(|p| json!({ "position": p }))); + } } let mut out = Vec::new(); for place in places { @@ -4101,6 +4125,9 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { if let Some(inp) = d.inputs.first_mut() { @@ -4665,3 +4692,13 @@ mod refused_ok_tests { assert!(bore.to_string().contains("\"depth\":\"5\""), "{bore}"); } } + +/// Whether two picks are the same face of the same body (wherever on it they were made). +fn same_face(a: &Sel, b: &Sel) -> bool { + matches!((a, b), (Sel::Face { body: ba, index: ia, .. }, Sel::Face { body: bb, index: ib, .. }) if ba == bb && ia == ib) +} + +/// Where a face was picked. +fn face_point(x: &Sel) -> Option { + if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None } +} diff --git a/crates/ui-egui/src/hole_direction_ui_tests.rs b/crates/ui-egui/src/hole_direction_ui_tests.rs new file mode 100644 index 00000000..014394f6 --- /dev/null +++ b/crates/ui-egui/src/hole_direction_ui_tests.rs @@ -0,0 +1,135 @@ +//! Which way a hole picked in the viewport drills, as the dialog's arrow shows it and as the +//! result has it: into the face that was picked (also the underside of a thin plate on XY, and a +//! round face), and, at sketch points, into the part unless Flip is on. + +use serde_json::json; +use solidcraft_engine::doc::FeatureKind; +use solidcraft_engine::geom::Vec3; + +use crate::scenario::Harness; + +fn solid_at(h: &Harness, p: Vec3) -> bool { + h.app.session.model.state().bodies.iter().any(|b| b.body.contains(p)) +} + +fn screen(h: &mut Harness, p: Vec3) -> [f64; 2] { + h.at(&json!({"world": [p.x, p.y, p.z]})).unwrap() +} + +/// The open dialog's arrow: where it stands and the outward normal it points against. +fn arrow(h: &Harness) -> (Vec3, Vec3) { + let d = h.app.dialog.as_ref().expect("the hole dialog"); + let (at, n) = crate::canvas::anchor(&h.app, d).expect("an anchor"); + (at, n.expect("an arrow")) +} + +fn last_hole_direction(h: &Harness) -> Vec3 { + match &h.app.session.doc.features.last().unwrap().kind { + FeatureKind::Hole { direction, .. } => *direction, + other => panic!("the last feature is not a hole: {}", other.type_name()), + } +} + +#[test] +fn a_hole_picked_under_a_thin_plate_on_xy_drills_up_into_it_as_its_arrow_shows() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("sketch.create", json!({"plane": "XY", "name": "S"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [150, 100]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("solid.extrude", json!({"sketch": "S", "distance": 1.5})).unwrap(); + h.step(&json!({"call": "ui.view", "params": {"view": "bottom"}})).unwrap(); + h.settle(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + let click = screen(&mut h, Vec3::new(75.0, 50.0, 0.0)); + h.step(&json!({"click": click})).unwrap(); + h.frames(2); + let (_, n) = arrow(&h); + assert!(n.dist(Vec3::new(0.0, 0.0, -1.0)) < 1e-6, "the arrow stands on the underside, facing down: {n:?}"); + if let Some(crate::dialogs::Dialog { kind: crate::dialogs::Kind::Hole { depth, opts, .. }, .. }) = h.app.dialog.as_mut() { + *depth = "1 mm".into(); + opts.all = false; + opts.angled = false; + } + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "OK closed the dialog"); + // The hole goes up into the plate, 1 mm, against the arrow's normal (it used to go through). + let dir = last_hole_direction(&h); + assert!(dir.dist(-n) < 1e-6, "the hole drills along {dir:?}, the arrow says {:?}", -n); + assert!(!solid_at(&h, Vec3::new(75.0, 50.0, 0.5)), "the bore starts at the underside"); + assert!(solid_at(&h, Vec3::new(75.0, 50.0, 1.25)), "and stops 1 mm in"); +} + +#[test] +fn a_hole_picked_on_a_round_bar_drills_to_its_axis_as_its_arrow_shows() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("solid.cylinder", json!({"radius": 10, "height": 40})).unwrap(); + h.step(&json!({"call": "ui.view", "params": {"view": "front"}})).unwrap(); + h.settle(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + // The side facing the viewer (-y), at mid-height. + let click = screen(&mut h, Vec3::new(0.0, -10.0, 20.0)); + h.step(&json!({"click": click})).unwrap(); + h.frames(2); + let (at, n) = arrow(&h); + assert!((at.dist(Vec3::new(0.0, -10.0, 20.0))) < 0.5, "the round face is picked where clicked: {at:?}"); + assert!(n.dist(Vec3::new(0.0, -1.0, 0.0)) < 0.05, "the arrow stands out of the face, radially: {n:?}"); + if let Some(crate::dialogs::Dialog { kind: crate::dialogs::Kind::Hole { depth, diameter, opts, .. }, .. }) = h.app.dialog.as_mut() { + *depth = "5 mm".into(); + *diameter = "4 mm".into(); + opts.all = false; + opts.angled = false; + } + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "OK closed the dialog: {:?}", h.app.dialog.as_ref().map(|d| &d.error)); + let holes = h.app.session.doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::Hole { .. })).count(); + assert_eq!(holes, 1, "one hole where it was clicked (the round face is picked whole, both halves)"); + let dir = last_hole_direction(&h); + assert!(dir.dist(Vec3::new(0.0, 1.0, 0.0)) < 0.05, "the hole drills toward the axis: {dir:?}"); + assert!(!solid_at(&h, Vec3::new(at.x, -7.5, 20.0)), "the bore is 5 mm deep"); + assert!(solid_at(&h, Vec3::new(at.x, -4.5, 20.0)), "and stops there"); +} + +#[test] +fn flip_drills_holes_at_sketch_points_the_other_way() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap(); + // A point on a plane through the middle of the block: by itself the hole goes down from the top. + h.app.run("sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "P"})).unwrap(); + h.app.run("sketch.point", json!({"point": [10, 10], "id": "q"})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let sketch = h.app.session.doc.find_feature("P").unwrap().id; + h.step(&json!({"start": "solid.hole"})).unwrap(); + if let Some(d) = h.app.dialog.as_mut() { + if let crate::dialogs::Kind::Hole { depth, opts, .. } = &mut d.kind { + opts.multiple = true; + opts.all = false; + opts.angled = false; + *depth = "5 mm".into(); + } + d.inputs = vec![crate::selection::SelInput::new("Points", crate::selection::POINTS, true)]; + d.inputs[0].items = vec![solidcraft_engine::Sel::SketchPoint { id: format!("{sketch}:q") }]; + } + h.frames(2); + // The arrow shows the way the hole goes: down from the top, and up once Flip is on. + let (_, n) = arrow(&h); + assert!(n.dist(Vec3::new(0.0, 0.0, 1.0)) < 1e-9, "unflipped, the hole goes down: the arrow stands on +z ({n:?})"); + h.app.dialog.as_mut().unwrap().extra.insert("flip".into(), json!(true)); + h.frames(2); + let (_, n) = arrow(&h); + assert!(n.dist(Vec3::new(0.0, 0.0, -1.0)) < 1e-9, "flipped, it goes up: {n:?}"); + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "OK closed the dialog: {:?}", h.app.dialog.as_ref().map(|d| &d.error)); + assert!(!solid_at(&h, Vec3::new(10.0, 10.0, 2.5)), "flipped: the hole comes up from the bottom"); + assert!(solid_at(&h, Vec3::new(10.0, 10.0, 17.5)), "and not down from the top"); + // Edit Feature shows Flip on, and OK keeps it. + let id = h.app.session.doc.features.last().unwrap().id; + h.step(&json!({"call": "ui.editFeature", "params": {"feature": id}})).unwrap(); + h.frames(4); + let d = h.app.dialog.as_ref().expect("the hole dialog"); + assert_eq!(d.extra.get("flip"), Some(&json!(true))); + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(!solid_at(&h, Vec3::new(10.0, 10.0, 2.5)) && solid_at(&h, Vec3::new(10.0, 10.0, 17.5))); +} diff --git a/crates/ui-egui/src/hole_placement_ui_tests.rs b/crates/ui-egui/src/hole_placement_ui_tests.rs index d2c44212..d93ac6fb 100644 --- a/crates/ui-egui/src/hole_placement_ui_tests.rs +++ b/crates/ui-egui/src/hole_placement_ui_tests.rs @@ -467,12 +467,12 @@ fn select_other_over_a_dense_cluster_off_the_face_tests_no_triangles() { #[test] fn select_other_over_a_dense_cluster_by_a_curved_face_looks_it_up_once() { // The points in a sketch through the cylinder's axis, seen from the front: the face under the - // cursor is the curved side, which takes no hole. It is looked up once, its triangles not at - // all, and nothing is offered. + // cursor is the curved side. A hole goes on it where it is picked (sketch points snap only + // on flat faces): it is looked up once, its triangles not at all, and only the face is offered. let mut c = cylinder_with_cluster(json!({"plane": "XZ"}), [5.0, -10.0], "front"); let many = c.many; let (list, (lookups, tested)) = c.select_other(); - assert_eq!(list["open"], json!(false), "nothing for a Single hole here: {list}"); + assert_eq!((rows_named(&list, "Point"), rows_named(&list, "Face")), (0, 1), "the curved face alone: {list}"); assert_eq!(lookups, 1, "the curved face is looked up once for {many} points"); assert_eq!(tested, 0, "no triangle of a curved face is looked at"); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c277dda5..afc4e37f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod gizmo; pub mod gpu; pub mod help; #[cfg(test)] +mod hole_direction_ui_tests; +#[cfg(test)] mod hole_placement_ui_tests; pub mod home; pub mod i18n;