mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-11 11:11:59 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27423c2b78 | |||
| f5d9538979 | |||
| 32f791000d |
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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"}));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user