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 <[email protected]>

* Holes at sketch points show their arrow: into the part, or away with Flip (#967)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-14 02:10:09 -04:00
1 parent 47a7f29485
commit b34d45233d
11 files changed
+247 -10

No files matched your search

+3
View File
@@ -1131,6 +1131,9 @@ pub struct MeshData {
pub struct SketchPoints {
pub sketch: u64,
pub ids: Vec<String>,
/// 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
+12
View File
@@ -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<Vec3> {
let ss = st.sketch(sketch)?;
let spots: Vec<Vec3> =
ids.iter().map(|id| ss.sketch.points.iter().find(|p| &p.id == id).map(|p| ss.plane.to_world(p.pos))).collect::<Option<_>>()?;
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>, 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>, Vec3)> {
let v: Vec<Vec3> =
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 => {
+3
View File
@@ -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.
+1 -1
View File
@@ -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;
+3 -2
View File
@@ -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<Value> {
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,
};
+21
View File
@@ -506,6 +506,27 @@ fn cases() -> Vec<Case> {
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(
+24 -1
View File
@@ -90,7 +90,30 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
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<String>)> = 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::<u64>()
{
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)),
+40 -3
View File
@@ -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<Vec<(String, Value)>, 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<Sel> = 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<usize>) -> Option<Dia
if let Some(inp) = d.inputs.first_mut() {
inp.items = sp.ids.iter().map(|id| Sel::SketchPoint { id: format!("{}:{id}", sp.sketch) }).collect();
}
if sp.flip {
d.extra.insert("flip".into(), json!(true));
}
}
None => {
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<Value> {
if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }
}
@@ -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)));
}
@@ -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");
}
+2
View File
@@ -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;