Compare commits

..

3 Commits

Author SHA1 Message Date
dudedagger42 27423c2b78 Extrude To Object: refuse an offset past a curved face instead of cutting nothing (#12617) (#13463)
A Join or Cut To Object on a curved or slanted face is cut back to that face, which removes the
part a To offset adds past it, so the offset changed nothing and the Cut took nothing off. It now
fails with a reason; an offset of 0 or short of the face is not refused.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:24:36 +00:00
dudedagger42 f5d9538979 Pattern on Path: curves listed out of chain order make the same path (#12199) (#12965)
* Pattern on Path: test that curves listed out of chain order make the same path (#12199)

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

* Pattern on Path: order curves listed out of chain order into one chain (#12199)

path_segments_at walked the curves in the listed order and refused one that
did not start where the last ended. When the list is not end to end, chain the
curves by their ends first; a real gap still gives the same error.

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

* Pattern on Path: rustfmt; test that a path with a gap is still refused (#12199)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:07:52 +00:00
dudedagger42 32f791000d Hole: a sketch on an offset plane inside the part starts at the plane (#11538) (#12919)
* Hole: a sketch on an offset plane inside the part starts the hole at the plane (#11538)

hole_on_surface moved every sketch point inside a body back to the body's
surface, so a hole from a sketch on an offset or construction plane started at
the top face instead of the plane. Sketch points keep their place when the
sketch plane is derived (offset, construction, through point ...); picked faces
and explicit planes still follow the surface as before.

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

* rustfmt

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

* Hole: standard planes keep drilling from the surface; test for it (#11538)

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

* Hole: patterned and mirrored copies start where the original does (#11538)

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

* Hole: designs saved before keep their sketch-point holes at the surface; mesh-decisions entry (#11538)

- format/pending/hole-sketch-plane-start.rs marks every saved hole at sketch points (not a Single
  hole) `old_surface_start`; with it a point inside a body still moves back to the surface, as
  before, whatever plane the sketch lies on. New holes leave it off; Edit Hole keeps it
  (`solid.hole` takes `old_surface_start`, edit.rs carries it over).
- xtask/mesh-decisions.txt: the entry names `hole_on_surface_in`, where the mesh test now lives.
- Test: a_saved_offset_plane_hole_keeps_starting_at_the_surface (reopened as format 50, then
  edited) and the step's own test.

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

* rustfmt

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

* Hole: a hole that starts inside the part leaves the material above it

A hole drawn on a plane through the part (#11538) kept the tool's reach above
the start, which is there so a tool's top never lies on a face. Inside the part
that reach cut material above the plane. When the point just above the start is
inside a body (exact classify_point), the reach is now 1e-4 of the part's size.

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

* Hole: test that a sketch plane just under the top face keeps the roof (#11538)

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

* Hole: an inside start is judged at the spot itself (#11538)

The shorter tool top for a hole starting inside the part is chosen only for
spots that stay inside (a sketch on a derived plane), judged at the spot, so a
plane just under a face keeps the thin roof above it.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:07:49 +00:00
12 changed files with 359 additions and 31 deletions
+6
View File
@@ -1019,6 +1019,12 @@ pub enum FeatureKind {
/// part changes. `position` keeps where it was placed.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
references: Vec<HoleReference>,
/// Holes at sketch points as older builds drilled them: a point inside a body moves back
/// to the body's surface whatever plane the sketch lies on. Designs saved before #11538
/// carry it, so their holes rebuild where they were; new holes leave it off, and Edit
/// Hole keeps it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
old_surface_start: bool,
},
/// A thread on a cylindrical face. Cosmetic by default (no geometry change; drawn and
/// exported as an annotation); `modeled` cuts the real helical ISO profile into the face.
+124 -13
View File
@@ -5065,6 +5065,12 @@ fn line_meets_box(p: Vec3, dir: Vec3, b: &solidcraft_geom::Aabb3) -> bool {
/// bodies in `scope` count (every body when it is empty, see [`hole_scope`]). Edit Hole seeds from
/// this point so the dialog's face pick matches where evaluation drills (#10689).
pub fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) -> Vec3 {
hole_on_surface_in(st, p, dir, scope, true)
}
/// [`hole_on_surface`]; with `from_inside` false a position inside a body stays where it is (a
/// hole from a sketch on an explicit plane starts at that plane, #11538).
fn hole_on_surface_in(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String], from_inside: bool) -> Vec3 {
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
let eps = size * 1e-6;
let mut best: Option<Vec3> = None;
@@ -5082,6 +5088,9 @@ pub fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) ->
{
return p;
}
if !from_inside && m.contains(p) {
return p;
}
let q = if m.contains(p) { m.raycast(p, -dir).map(|(t, _)| p - dir * t) } else { m.raycast(p, dir).map(|(t, _)| p + dir * t) };
if let Some(q) = q
&& best.is_none_or(|bq| q.dist(p) < bq.dist(p))
@@ -5925,7 +5934,7 @@ fn backticked<S: AsRef<str>>(ids: &[S]) -> String {
/// Where a hole feature drills (each on the surface) and in which direction: at its sketch
/// points perpendicular to the sketch (into the material), else at its position. A point gone
/// from the sketch drops only its own hole ([`hole_points_gone`]); with none left it fails.
fn hole_spots(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
fn hole_spots(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
let FeatureKind::Hole { position, at, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
let (spots, dir) = match points {
Some(sp) if !sp.single => {
@@ -5951,7 +5960,35 @@ fn hole_spots(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> R
if spots.is_empty() || spots.len() > 10_000 {
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
}
Ok((spots.into_iter().map(|p| hole_on_surface(st, p, dir, hole_scope(f))).collect(), dir))
let from_inside = hole_from_inside(doc, f);
Ok((spots.into_iter().map(|p| hole_on_surface_in(st, p, dir, hole_scope(f), from_inside)).collect(), dir))
}
/// Whether a hole's spots that lie inside a body move back to its surface: not for the points
/// of a sketch on a derived plane (#11538), unless the hole was saved before that.
fn hole_from_inside(doc: &Document, f: &Feature) -> bool {
match &f.kind {
FeatureKind::Hole { old_surface_start: true, .. } => true,
FeatureKind::Hole { points: Some(sp), .. } if !sp.single => sketch_plane_follows_face(doc, sp.sketch.0),
_ => true,
}
}
/// Whether the sketch lies on a picked face, a standard plane (or a plane given outright), so a hole at its points
/// follows that face back to the surface; a sketch on an offset, construction or other derived
/// plane is where the user put it.
fn sketch_plane_follows_face(doc: &Document, sketch: u64) -> bool {
fn picked(p: &PlaneRef) -> bool {
match p {
PlaneRef::Origin { .. } | PlaneRef::Face { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } => true,
PlaneRef::Flipped { base, .. } => picked(base),
_ => false,
}
}
match doc.feature(sketch).map(|f| &f.kind) {
Some(FeatureKind::Sketch { plane, .. }) => picked(plane),
_ => true,
}
}
/// A hole's depth: down to the To Object (where the hole's axis meets the face there, else the
@@ -6112,16 +6149,22 @@ fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64,
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
/// straight cylinder (see [`hole_tools_and_prisms`]).
fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
fn hole_feature_tools(
doc: &Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
f: &Feature,
) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
let FeatureKind::Hole { diameter, depth, hole, to, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
let (spots, dir) = hole_spots(vals, st, f)?;
let (spots, dir) = hole_spots(doc, vals, st, f)?;
if spots.is_empty() || spots.len() > 10_000 {
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
}
let kept_inside = !hole_from_inside(doc, f);
let mut tools = Vec::new();
for at in spots {
let depth = hole_depth_to(st, &at, dir, &to.map(Vec3::from), depth)?;
tools.extend(hole_tools_and_prisms(vals, st, &at, dir, diameter, &depth, hole)?);
tools.extend(hole_tools_and_prisms(vals, st, &at, dir, diameter, &depth, hole, kept_inside)?);
}
Ok(tools)
}
@@ -6205,7 +6248,8 @@ fn sheet_hole_cut(
Ok(handled)
}
/// The cutting tools of one hole at `position`, drilling along `dir`.
/// The cutting tools of one hole at `position`, drilling along `dir` (`kept_inside`: see
/// [`hole_tools_and_prisms`]).
#[allow(clippy::too_many_arguments)]
fn hole_tools(
vals: &BTreeMap<String, Value>,
@@ -6215,8 +6259,9 @@ fn hole_tools(
diameter: &str,
depth: &Option<crate::Expr>,
hole: &HoleKind,
kept_inside: bool,
) -> Result<Vec<Traced>> {
Ok(hole_tools_and_prisms(vals, st, position, dir, diameter, depth, hole)?.into_iter().map(|(tool, _)| tool).collect())
Ok(hole_tools_and_prisms(vals, st, position, dir, diameter, depth, hole, kept_inside)?.into_iter().map(|(tool, _)| tool).collect())
}
/// The scale a hole's tools are sized by: the largest body the hole's axis meets (#2904), so a
@@ -6343,12 +6388,21 @@ fn hole_tools_and_prisms(
diameter: &str,
depth: &Option<crate::Expr>,
hole: &HoleKind,
kept_inside: bool,
) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
let r = val(vals, diameter, Kind::Length)? / 2.0;
if !(r > 1e-6) {
return Err(DocError::Invalid("hole diameter must be positive".into()));
}
let size = hole_axis_size(st, position, dir);
let mut top = (size * 0.01).max(0.1);
// A hole whose spot stays inside the part (`kept_inside`: a sketch on a plane through it,
// #11538) starts there: the reach above a face, there so the tool's top never lies on that
// face, would cut the material above the plane. Judged at the spot itself, so a plane just
// under a face keeps the thin roof above it.
if kept_inside && st.bodies.iter().any(|b| matches!(b.body.classify_point(*position), Ok(kernel::PointClass::Inside))) {
top = size * 1e-4;
}
// Tools are cylinders and cones made by (tapered) extrudes along the axis, cut one
// after another, widest first.
let plane = Plane::from_normal(*position, -dir).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
@@ -6359,7 +6413,7 @@ fn hole_tools_and_prisms(
}
HoleKind::Simple | HoleKind::Drilled { .. } => r,
};
let (top, above) = hole_run_on(st, &plane, if wide.is_finite() { wide } else { r }, (size * 0.01).max(0.1));
let (top, above) = hole_run_on(st, &plane, if wide.is_finite() { wide } else { r }, top);
let bottom = match depth {
Some(d) => -val(vals, d, Kind::Length)?,
None => -(size * 2.0 + 1.0),
@@ -6945,6 +6999,17 @@ fn feature_tools_with_constructor(
// To a curved (or slanted) face: run right through the target body, then keep what
// lies before its surface (the tool less the body, the piece at the start).
let curved = curved_face(&tst, to_face, Some(n));
// The tool is cut back to that surface below, which removes the part a To offset adds
// past it: the offset would change nothing, so say so instead of cutting nothing (#12617).
if curved.is_some()
&& matches!(operation, Operation::Join | Operation::Cut)
&& let Some(e) = &extent.to_offset
&& val(vals, e, Kind::Length)? > 1e-9
{
return Err(DocError::Invalid(
"not supported yet: To Object with an offset past a curved or slanted face (the tool is cut back to that face, so the offset would do nothing); use a Distance, or an offset of 0 or less".into(),
));
}
if let Some(bi) = curved
&& let Some(tb) = tst.bodies.get(bi)
{
@@ -7375,7 +7440,7 @@ fn feature_tools_with_constructor(
val(vals, top_radius, Kind::Length)?,
val(vals, height, Kind::Length)?,
)?]),
FeatureKind::Hole { .. } => Ok(hole_feature_tools(vals, st, f)?.into_iter().map(|(tool, _)| tool).collect()),
FeatureKind::Hole { .. } => Ok(hole_feature_tools(doc, vals, st, f)?.into_iter().map(|(tool, _)| tool).collect()),
_ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))),
}
}
@@ -7777,6 +7842,9 @@ pub(crate) fn path_segments_at(ps: &SolvedSketch, ids: &[String], start: Vec3, p
let reversed = |c: Vec<solidcore_record::Seg2>| c.into_iter().rev().map(|s| s.reversed()).collect::<Vec<_>>();
// Where two curves meet: one tolerance for telling which ends touch and for the joint.
const JOINT: f64 = 1e-5;
// Curves listed out of chain order are one chain all the same (#12199): when the list is
// not already end to end, put each next curve where one of its ends meets the chain's end.
curves = chain_order(curves, JOINT);
// Orient the first curve: it runs toward the second where it meets it at one end only;
// otherwise (one curve, or a second meeting it at both ends) from the end nearest `start`
// (no start given, a pipe: as drawn).
@@ -7811,6 +7879,47 @@ pub(crate) fn path_segments_at(ps: &SolvedSketch, ids: &[String], start: Vec3, p
Ok(segs.iter().map(|s| kernel::PathSeg::of_sketch(&ps.plane, s)).collect())
}
/// `curves` (each a run of segments) put into one chain, when they are not end to end as listed.
/// The chain starts at the first listed curve with an end that touches no other curve (the first
/// curve when they close into a ring) and goes on with the first listed unused curve that meets
/// its end. Curves that meet nothing keep their listed order after the chain, so a real gap is
/// still reported by the caller.
fn chain_order(curves: Vec<Vec<solidcore_record::Seg2>>, joint: f64) -> Vec<Vec<solidcore_record::Seg2>> {
let ends: Vec<Option<(Vec2, Vec2)>> = curves.iter().map(|c| c.first().zip(c.last()).map(|(f, l)| (f.start(), l.end()))).collect();
let meets = |p: Vec2, q: Vec2| p.dist(q) <= joint;
// Already end to end as listed: nothing to do.
let connected = ends.windows(2).all(|w| match (w[0], w[1]) {
(Some((a, b)), Some((c, d))) => meets(a, c) || meets(a, d) || meets(b, c) || meets(b, d),
_ => true,
});
if curves.len() < 3 || connected {
return curves;
}
let n = curves.len();
// Whether end `p` of curve `i` meets an end of any other curve.
let shared = |i: usize, p: Vec2| (0..n).any(|j| j != i && ends[j].is_some_and(|(a, b)| meets(a, p) || meets(b, p)));
let head = (0..n).find(|&i| ends[i].is_some_and(|(a, b)| !shared(i, a) || !shared(i, b))).or_else(|| (0..n).find(|&i| ends[i].is_some()));
let Some(head) = head else { return curves };
let mut used = vec![false; n];
let mut order = vec![head];
used[head] = true;
// The head's open end is where the chain begins; it runs toward the other.
let mut tail = match ends[head] {
Some((a, b)) if !shared(head, a) => b,
Some((a, _)) => a,
None => return curves,
};
while let Some(next) = (0..n).find(|&j| !used[j] && ends[j].is_some_and(|(a, b)| meets(a, tail) || meets(b, tail))) {
let Some((a, b)) = ends[next] else { break };
tail = if meets(a, tail) { b } else { a };
used[next] = true;
order.push(next);
}
order.extend((0..n).filter(|&j| !used[j]));
let mut slots: Vec<Option<Vec<solidcore_record::Seg2>>> = curves.into_iter().map(Some).collect();
order.into_iter().filter_map(|i| slots.get_mut(i).and_then(Option::take)).collect()
}
/// A closed chain `segs` (in the plane `path`) begun where `profile`'s plane crosses it nearest
/// `start`: the curve there split in two, its second part first and its first part last (or the
/// chain begun at the joint there). Unchanged when the chain is open, already starts on the
@@ -8690,6 +8799,8 @@ fn replay(
// a blind hole's depth counts from the face at the copy, not from the bottom of the
// original hole under the original spot.
let (spots, dir) = hole_spots_raw(vals, st, src)?;
// Copies start where the original does (#11538).
let from_inside = hole_from_inside(doc, src);
// A hole at sketch points drills into the material, judged at each copy's spots as
// the source's side was judged at its own (#14050): judged at the source spots, where
// the source has already left an empty bore, a through hole's copies turned away.
@@ -8710,12 +8821,12 @@ fn replay(
let start = copies.len();
let mut on = Vec::with_capacity(spots.len());
for p in &spots {
let at = hole_on_surface(st, crate::apply_point(m, *p), d, hole_scope(src));
let at = hole_on_surface_in(st, crate::apply_point(m, *p), d, hole_scope(src), from_inside);
on.push((at, d));
let to = to.map(|t| crate::apply_point(m, t.0));
let depth = hole_depth_to(st, &at, d, &to, depth)?;
// Each copy's faces are named as the hole's, with the instance.
copies.extend(hole_tools(vals, st, &at, d, diameter, &depth, hole)?.into_iter().map(|t| Traced {
copies.extend(hole_tools(vals, st, &at, d, diameter, &depth, hole, !from_inside)?.into_iter().map(|t| Traced {
lineage: t.lineage.map(|l| l.copied(src.id, first + k + 1)),
body: t.body,
recipe: None,
@@ -9281,7 +9392,7 @@ fn eval_feature(
if thread_depth.is_some_and(|d| !d.is_finite() || d <= 0.0) {
return Err(DocError::Invalid("thread depth must be finite and positive".into()));
}
let (spots, dir) = hole_spots(vals, st, f)?;
let (spots, dir) = hole_spots(doc, vals, st, f)?;
let points_gone = hole_points_gone(st, f);
if !points_gone.is_empty() {
append_warning(
@@ -9302,7 +9413,7 @@ fn eval_feature(
} else {
Vec::new()
};
let tools = hole_feature_tools(vals, st, f)?;
let tools = hole_feature_tools(doc, vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
if handled.is_empty() {
+2
View File
@@ -1456,6 +1456,7 @@ fn a_tap_marking_searches_only_the_bodies_near_it() {
ease_out: None,
to: None,
references: Vec::new(),
old_surface_start: false,
};
let (work, threads) = marking_work(&mut doc, hole);
assert_eq!(work.cylinder_search, 1, "only the plate is searched");
@@ -1573,6 +1574,7 @@ fn a_tapped_hole_reads_the_bodies_along_its_drill_line() {
ease_out: None,
to: None,
references: Vec::new(),
old_surface_start: false,
};
let (work, threads) = marking_work(&mut doc, hole);
assert_eq!(work.tapped_wall, 3, "Plate, Below and Grazing are read");
@@ -0,0 +1,47 @@
//! A hole at the points of a sketch on an offset, construction or other derived plane now starts
//! at that plane when the plane lies inside a body, instead of at the body's surface (#11538).
//! Older builds moved every such point back to the surface, so this step marks every saved hole
//! at sketch points (not a Single hole) `old_surface_start`, which keeps that reading: designs
//! saved before rebuild their holes where they were. Holes at a position, Single holes and holes
//! made or opened from now on are unchanged. The last release asks for an update instead of
//! opening a design that carries this change.
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
let Some(Value::Array(features)) = v.get_mut("features") else {
return Ok(());
};
for feature in features {
if feature.get("type").and_then(Value::as_str) != Some("hole") {
continue;
}
let at_points =
feature.get("points").and_then(Value::as_object).is_some_and(|points| !points.get("single").and_then(Value::as_bool).unwrap_or(false));
if let (true, Some(object)) = (at_points, feature.as_object_mut()) {
object.insert("old_surface_start".into(), Value::Bool(true));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn saved_holes_at_sketch_points_keep_starting_at_the_surface() {
let mut v = json!({"features": [
{"type": "hole", "points": {"sketch": 3, "ids": ["p1"]}},
{"type": "hole", "points": {"sketch": 3, "ids": ["p1", "p2"], "flip": true}},
{"type": "hole", "points": {"sketch": 3, "ids": ["p1"], "single": true}},
{"type": "hole", "position": [0.0, 0.0, 10.0]},
{"type": "extrude", "points": {"sketch": 3, "ids": ["p1"]}}
]});
super::step(&mut v).unwrap();
let marked: Vec<bool> = v["features"].as_array().unwrap().iter().map(|f| f["old_surface_start"] == json!(true)).collect();
assert_eq!(marked, [true, true, false, false, false]);
let mut empty = json!({"name": "Untitled"});
super::step(&mut empty).unwrap();
assert_eq!(empty, json!({"name": "Untitled"}));
}
}
+8
View File
@@ -2124,6 +2124,14 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
{
parameters.entry("old_auto_size").or_insert_with(|| json!(true));
}
// A hole saved before a sketch on a derived plane started its holes at that plane keeps
// starting at the surface when it is edited (#11538).
if let FeatureKind::Hole { old_surface_start: true, .. } = &old.kind
&& cid == "solid.hole"
&& let Some(parameters) = params.as_object_mut()
{
parameters.entry("old_surface_start").or_insert_with(|| json!(true));
}
// Members keep their names through an edit, unless it gives them another name.
if let FeatureKind::FrameMember { names, body_name, .. } = &old.kind
&& cid == "frame.member"
+3 -1
View File
@@ -134,7 +134,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | 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); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | 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); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body); old_surface_start?: bool (sketch points: a point inside a body starts the hole at the body's surface whatever plane the sketch lies on, as designs saved before #11538 drilled; set on those holes, keeps them as they were when edited)"),
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
.at("SOLID", "CREATE")
.icon("pipe")
@@ -3205,6 +3205,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(_) => Some(req_expr(cmd, p, "thread_depth")?),
};
let old_surface_start = p.get("old_surface_start").and_then(Value::as_bool).unwrap_or(false);
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
if modeled && thread.is_none() {
return Err(bad(cmd, "`modeled` needs `thread` (a tapped hole)"));
@@ -3429,6 +3430,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
ease_out,
to: to.map(FacePick),
references,
old_surface_start,
},
)
}
+49
View File
@@ -1296,6 +1296,55 @@ fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
}
/// #12199: a path whose curves are listed out of chain order is the same path as in chain order.
#[test]
fn pattern_on_path_orders_the_curves_into_one_chain() {
// Four lines: (10,10) → (30,10) → (30,30) → (60,30) → (60,10); 90 mm long.
let copies = |order: [&str; 4]| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [9, 9, 0], "body_name": "Pin"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
for (id, a, b) in [("l1", [10, 10], [30, 10]), ("l2", [30, 10], [30, 30]), ("l3", [30, 30], [60, 30]), ("l4", [60, 30], [60, 10])] {
run(&mut s, "sketch.line", json!({"points": [a, b], "connect": false, "ids": [id]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.pattern.path", json!({"bodies": ["Pin"], "path_sketch": "Path", "path": order, "count": 4, "spacing": 25}));
let m = run(&mut s, "inspect.measure", json!({}));
let mut at: Vec<(i64, i64)> = m["bodies"]
.as_array()
.unwrap()
.iter()
.map(|b| ((b["bbox"]["min"][0].as_f64().unwrap() * 1e3).round() as i64, (b["bbox"]["min"][1].as_f64().unwrap() * 1e3).round() as i64))
.collect();
at.sort();
at
};
// The copies sit 0, 25, 50 and 75 mm along the path, as displacements from its start.
let want = vec![(9_000, 9_000), (29_000, 14_000), (39_000, 29_000), (59_000, 24_000)];
assert_eq!(copies(["l1", "l2", "l3", "l4"]), want);
assert_eq!(copies(["l1", "l3", "l2", "l4"]), want);
assert_eq!(copies(["l4", "l3", "l2", "l1"]), copies(["l4", "l2", "l3", "l1"]));
}
/// #12199: putting the listed curves in chain order does not join curves with a gap between
/// them: such a path is still refused.
#[test]
fn pattern_on_path_with_a_gap_is_still_refused() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [9, 9, 0], "body_name": "Pin"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
// (10,10) → (30,10), then a 20 mm gap, then (30,30) → (60,30).
for (id, a, b) in [("l1", [10, 10], [30, 10]), ("l3", [30, 30], [60, 30])] {
run(&mut s, "sketch.line", json!({"points": [a, b], "connect": false, "ids": [id]}));
}
run(&mut s, "sketch.finish", json!({}));
for order in [["l1", "l3"], ["l3", "l1"]] {
let p = json!({"bodies": ["Pin"], "path_sketch": "Path", "path": order, "count": 3, "spacing": 10});
let error = s.execute("solid.pattern.path", &p).unwrap_err().to_string();
assert!(error.contains("not connected end to end"), "{order:?}: {error}");
}
}
/// #12585: a pattern or mirror of an Offset Face repeats it at the copied faces instead of
/// failing with "cannot be patterned or mirrored yet". A 40 x 30 x 20 box whose top is split into
/// strips at x = 10 and x = 30 has its left strip raised 5; the copy raises the right strip.
+32
View File
@@ -235,6 +235,38 @@ fn cut_to_the_far_side_of_a_curved_wall() {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
/// #12617: a To Object offset past a curved face used to change nothing: the tool is cut back to
/// the face, which removes the very part the offset added, so the Cut took nothing off. It is
/// refused with a reason instead. An offset of 0 or short of the face is not refused.
#[test]
fn a_to_offset_past_a_curved_face_is_refused_not_ignored() {
let window = |s: &mut Session, to_offset: f64| {
run(s, "sketch.create", json!({"plane": {"origin": [0, -30, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}}));
run(s, "sketch.rectangle.two_point", json!({"p0": [-3, 8], "p1": [3, 12]}));
run(s, "sketch.finish", json!({}));
s.execute("solid.extrude", &json!({"to": [0, -18, 10], "to_offset": to_offset, "operation": "cut"}))
};
let tube = || {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 20, "height": 20, "body_name": "Tube"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Bore"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 18}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Bore", "distance": 20, "operation": "cut"}));
s
};
let mut s = tube();
let whole = body(&mut s, "Tube")["volume_mm3"].as_f64().unwrap();
let err = window(&mut s, 2.0).expect_err("an offset past the curved wall is refused");
assert!(err.to_string().contains("curved") && err.to_string().contains("offset"), "{err}");
assert_eq!(body(&mut s, "Tube")["volume_mm3"].as_f64().unwrap(), whole, "a refused cut changes nothing");
// No offset, and an offset short of the wall, are not refused.
for to_offset in [0.0, -1.0] {
let mut s = tube();
window(&mut s, to_offset).unwrap_or_else(|e| panic!("offset {to_offset}: {e}"));
}
}
/// #13188: a stored taper of 0 is a straight extrusion, also for a profile with a hole.
#[test]
fn extrude_with_a_taper_of_zero_on_a_profile_with_a_hole_is_straight() {
+84
View File
@@ -577,6 +577,90 @@ fn a_hole_placed_on_another_parts_face_drills_its_own_part_behind_it() {
drilled_base_only(&mut opened);
}
/// #11538: a sketch on an offset plane inside the part is where the hole starts, not the part's
/// top face above it. A 5 mm blind hole from z 12 removes z 7..12.
#[test]
fn hole_from_a_sketch_on_an_offset_plane_inside_the_part_starts_at_the_plane() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "12 mm", "name": "Pts"}));
run(&mut s, "sketch.point", json!({"point": [20, 20], "id": "p1"}));
run(&mut s, "sketch.finish", json!({}));
let base = volume(&mut s);
drill(&mut s, &["p1".to_string()], json!({"depth": 5}));
// The tool runs a hair past its start, so the walls are checked, not the volume.
assert!(solid_at(&s, 20.0, 20.0, 6.5) && !solid_at(&s, 20.0, 20.0, 7.5) && !solid_at(&s, 20.0, 20.0, 11.5));
assert!(solid_at(&s, 20.0, 20.0, 14.5) && solid_at(&s, 20.0, 20.0, 18.0) && base - volume(&mut s) < pi() * 25.0 * 6.0);
// Nothing above the plane goes: the tool's reach above a face is not used inside the part.
assert!(solid_at(&s, 20.0, 20.0, 12.05) && solid_at(&s, 20.0, 20.0, 12.3), "the material just above the plane stays");
}
/// #11538: a sketch plane just under the top face (z 19.95 in a 20 mm part) is still a start
/// inside the part: the tool's reach above a face, wider than the 0.05 mm roof, does not open it.
#[test]
fn hole_from_a_sketch_plane_just_under_the_top_face_keeps_the_roof() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "19.95 mm", "name": "Pts"}));
run(&mut s, "sketch.point", json!({"point": [20, 20], "id": "p1"}));
run(&mut s, "sketch.finish", json!({}));
drill(&mut s, &["p1".to_string()], json!({"depth": 5}));
assert!(!solid_at(&s, 20.0, 20.0, 17.0) && !solid_at(&s, 20.0, 20.0, 19.9), "the hole is drilled below the plane");
assert!(solid_at(&s, 20.0, 20.0, 19.98), "the roof above the plane stays");
}
/// A sketch on a standard plane through the middle of a part keeps drilling from the part's
/// surface, as before #11538 limited the new rule to derived planes.
#[test]
fn hole_from_a_sketch_on_a_standard_plane_inside_the_part_still_starts_at_the_surface() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 20, "corner": [-20, -20, -10], "name": "Part"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Pts"}));
run(&mut s, "sketch.point", json!({"point": [0, 0], "id": "p1"}));
run(&mut s, "sketch.finish", json!({}));
drill(&mut s, &["p1".to_string()], json!({"depth": 5}));
// Starts at the top (z 10) going down, not at z 0.
assert!(!solid_at(&s, 0.0, 0.0, 7.5) && solid_at(&s, 0.0, 0.0, 3.0) && solid_at(&s, 0.0, 0.0, -3.0));
}
/// The copies of a hole from a sketch on an offset plane start at the plane too (#11538).
#[test]
fn patterned_copy_of_an_offset_plane_hole_starts_at_the_plane_too() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "12 mm", "name": "Pts"}));
run(&mut s, "sketch.point", json!({"point": [10, 20], "id": "p1"}));
run(&mut s, "sketch.finish", json!({}));
drill(&mut s, &["p1".to_string()], json!({"depth": 5}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["H"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 15, "name": "Row"}));
for x in [10.0, 25.0] {
assert!(solid_at(&s, x, 20.0, 6.5) && !solid_at(&s, x, 20.0, 7.5) && !solid_at(&s, x, 20.0, 11.5) && solid_at(&s, x, 20.0, 14.5), "x {x}");
}
}
/// A design saved before #11538 keeps its hole from a sketch on an offset plane inside the part
/// where it was: starting at the top face (z 20) and 5 mm deep, also after an edit.
#[test]
fn a_saved_offset_plane_hole_keeps_starting_at_the_surface() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "12 mm", "name": "Pts"}));
run(&mut s, "sketch.point", json!({"point": [20, 20], "id": "p1"}));
run(&mut s, "sketch.finish", json!({}));
drill(&mut s, &["p1".to_string()], json!({"depth": 5}));
// As the release before saved it: the last folded format, none of today's pending changes.
let mut v: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
v["format"] = json!("solidcraft/50");
v.as_object_mut().unwrap().remove("format_pending");
let mut opened = Session::new(solidcraft_doc::Document::from_json(&v.to_string()).unwrap());
let from_surface = |s: &Session| {
!solid_at(s, 20.0, 20.0, 19.0) && !solid_at(s, 20.0, 20.0, 15.5) && solid_at(s, 20.0, 20.0, 14.0) && solid_at(s, 20.0, 20.0, 11.5)
};
assert!(from_surface(&opened));
run(&mut opened, "timeline.edit", json!({"feature": "H", "set": {"diameter": 8}}));
assert!(from_surface(&opened), "an edited saved hole still starts at the surface");
}
/// #10697: a countersink's cone stops at the hole's depth. A 3.1 mm deep countersunk hole in a
/// 3.2 mm plate leaves its 0.1 mm floor (the cone used to run on to 90 % of the hole radius and
/// open the bottom); a hole shallower than its countersink is cut to the hole's depth only.
+1
View File
@@ -7322,6 +7322,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
ease_out,
to,
references,
old_surface_start: _,
} => {
let mut d = start("solid.hole")?;
let mut k = hole_defaults(s);
+2 -16
View File
@@ -7,20 +7,7 @@ set -euo pipefail
root=$(git rev-parse --show-toplevel)
tmp=$(mktemp -d)
# Cleanup never changes the result of the check. A background git process (auto gc, maintenance) can
# still be writing into .git when this runs ("rm: cannot remove .git: Directory not empty"), so retry
# briefly, and ignore a failure: the temp directory is the system's to clear, the check already ran.
cleanup() {
local status=$?
trap - EXIT
set +e
for _ in 1 2 3 4 5 6 7 8 9 10; do
rm -rf "$tmp" 2>/dev/null && [ ! -e "$tmp" ] && break
sleep 0.2
done
exit "$status"
}
trap cleanup EXIT
trap 'rm -rf "$tmp"' EXIT
repo="$tmp/repo"
mkdir -p "$repo/crates/doc/src/drawing" "$repo/scripts"
cp -r "$root/crates/doc/src/format" "$repo/crates/doc/src/"
@@ -28,8 +15,7 @@ cp -r "$root/crates/doc/src/drawing/format" "$repo/crates/doc/src/drawing/"
cp "$root/crates/doc/build.rs" "$repo/crates/doc/"
cp "$root/scripts/fold-format-pending.sh" "$repo/scripts/"
rm -f "$repo"/crates/doc/src/format/pending/*.rs "$repo"/crates/doc/src/drawing/format/pending/*.rs # start from a release: nothing pending
# No background gc or maintenance in the throwaway repo: nothing may still be writing to .git at cleanup.
git() { command git -C "$repo" -c user.name=test -c user.email=test@example.com -c gc.auto=0 -c maintenance.auto=false -c gc.autoDetach=false -c core.fsmonitor=false "$@"; }
git() { command git -C "$repo" -c user.name=test -c user.email=test@example.com "$@"; }
git init -q -b main
git add -A
git commit -qm base
+1 -1
View File
@@ -20,7 +20,7 @@ crates/doc/src/eval.rs:feature_tools_with_constructor reaches a To Object extent
crates/doc/src/eval.rs:height_at measures a point's height on a face by the display mesh
crates/doc/src/eval.rs:hole_axis_size sizes a hole's tools by the bodies its axis meets on the display mesh
crates/doc/src/eval.rs:hole_depth_to works out a hole's To Object depth on the display mesh
crates/doc/src/eval.rs:hole_on_surface moves a hole back onto its face by the display mesh
crates/doc/src/eval.rs:hole_on_surface_in moves a hole back onto its face by the display mesh
crates/doc/src/eval.rs:hole_reaches decides whether a drill reaches material by the display mesh
crates/doc/src/eval.rs:nearest_object_face picks an extrusion's To Object face by the display mesh
crates/doc/src/eval.rs:off_the_ends moves an edge point off the edge's ends by the display mesh