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;