Sketch on the sides of a thin extrude (#2722) (#5781)

* claim: #2722

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

* Sketch on the sides of a thin extrude (#2722)

A thin wall's sides lie off the sketch curves (half the thickness either
side, say), so no curve was under them: its side faces had no role, no
exact history name, and Create Sketch refused them as "can't keep an
attachment". The roles of a thin wall's sides are now
`wall:<curve>:left|right` (the side of the curve it runs beside, in the
direction the curve is drawn), and its open ends `wall:<curve>:start|end`.
They follow the thickness and side settings and survive a save.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rlartot
2026-10-25 01:48:35 +00:00
committed by GitHub
parent a5f7f5a921
commit 19e2800472
4 changed files with 235 additions and 17 deletions
+83
View File
@@ -526,6 +526,89 @@ impl ExactSketchCurves<'_> {
}
}
impl ExactSketchCurves<'_> {
/// The roles of a thin wall's side that runs parallel to some of the wall's `curves`, off
/// them by one of the wall's `distances`: `wall:<curve>:left` (or `:right`) for the side of
/// that curve, left or right of the direction it is drawn in. A thin wall's sides lie off the
/// sketch (by half its thickness, say), so [`find_all_along`](Self::find_all_along) finds no
/// curve under them. Every curve the side runs along and overlaps names it (the first its
/// name, the others aliases), in the sketch's order; empty when it is parallel to none, or
/// its distance is not one of the wall's.
pub(crate) fn find_offset(&self, generated: &Seg2, curves: &[String], distances: [f64; 2]) -> Vec<String> {
let wanted = |offset: f64, scale: f64| {
let tolerance = 1e-7 * scale.max(1.);
offset.abs() > tolerance && distances.iter().any(|d| (d - offset.abs()).abs() <= tolerance)
};
let side = |offset: f64| if offset > 0. { "left" } else { "right" };
let tau = std::f64::consts::TAU;
let mut roles = Vec::new();
for (id, _, originals) in &self.segments {
if !curves.iter().any(|c| c == id) {
continue;
}
let found = originals.iter().find_map(|original| match (*original, *generated) {
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
let direction = b - a;
let length = direction.len();
let scale = length.max((d - c).len());
if length <= 1e-12 {
return None;
}
let (left_c, left_d) = (direction.cross(c - a) / length, direction.cross(d - a) / length);
if (left_c - left_d).abs() > 1e-9 * scale.max(1.) || !wanted(left_c, scale) {
return None;
}
let (tc, td) = ((c - a).dot(direction) / direction.len2(), (d - a).dot(direction) / direction.len2());
(tc.min(td).max(0.) < tc.max(td).min(1.) - 1e-9).then(|| side(left_c))
}
(Seg2::Arc { center: oc, radius: or, start: os, sweep: ow }, Seg2::Arc { center: gc, radius: gr, start: gs, sweep: gw }) => {
let scale = or.max(gr);
if oc.dist(gc) > 1e-9 * scale.max(1.) || !wanted(gr - or, scale) || ow == 0. || gw == 0. {
return None;
}
// Drawn counter-clockwise, the curve's left is towards its centre.
let left = if ow > 0. { gr < or } else { gr > or };
let inside = |start: f64, sweep: f64, angle: f64| {
let low = if sweep >= 0. { start } else { start + sweep };
(angle - low).rem_euclid(tau) <= sweep.abs() + 1e-9
};
let overlaps = inside(os, ow, gs)
|| inside(os, ow, gs + gw)
|| inside(os, ow, gs + gw / 2.)
|| inside(gs, gw, os)
|| inside(gs, gw, os + ow / 2.)
|| ow.abs() >= tau - 1e-9
|| gw.abs() >= tau - 1e-9;
overlaps.then_some(if left { "left" } else { "right" })
}
_ => None,
});
// The end of an open chain: a straight cut across the curve, through its end point.
let cap = || {
let Seg2::Line { a: c, b: d } = *generated else { return None };
let width = (d - c).len();
if width <= 1e-12 || (width - (distances[0] + distances[1])).abs() > 1e-7 * width.max(1.) {
return None;
}
originals.iter().find_map(|original| {
[("start", original.start(), original.start_tangent()), ("end", original.end(), original.end_tangent())].into_iter().find_map(
|(end, point, tangent)| {
let across = (d - c) * (1. / width);
let on_line = across.cross(point - c).abs() <= 1e-7 * width.max(1.);
let along = (point - c).dot(across);
(tangent.dot(across).abs() <= 1e-7 && on_line && (-1e-7..=width + 1e-7).contains(&along)).then_some(end)
},
)
})
};
if let Some(side) = found.or_else(cap) {
roles.push(format!("wall:{id}:{side}"));
}
}
roles
}
}
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
match (original, generated) {
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
+39 -15
View File
@@ -4522,11 +4522,12 @@ fn whole_face_tool(
sketch: Some(ss),
region: Some(region),
caps: [Some(crate::lineage::extrude_cap_role(lo)), Some(crate::lineage::extrude_cap_role(hi))],
thin: None,
};
match operation {
// Out of the body from the face.
Operation::Join if (up && at_lo) || (!up && at_hi) => {
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss)).ok()?;
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss), None).ok()?;
let [built] = built.as_slice() else { return None };
let exact = kernel::face_prism(&target.body, face, hi - lo, true, &built.body).ok()?;
kernel::can_join_touching_faces(&target.body, &exact).then(|| (roles(r).tool(exact, Some(&built.body)), (lo, hi)))
@@ -4534,7 +4535,7 @@ fn whole_face_tool(
// Into the body from the face, its walls touching it at most.
Operation::Cut if ((up && at_hi) || (!up && at_lo)) && !kernel::cut_takes_only_the_face(&target.body, face, hi - lo) => {
// Some walls go into the body: those that only touch it are moved off.
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss)).ok()?;
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss), None).ok()?;
let [built] = built.as_slice() else { return None };
let (tool, off) = kernel::cut_tool(&target.body, face, hi - lo, Some(&built.body))?;
let span = if up { (lo, hi + off) } else { (lo - off, hi) };
@@ -4550,7 +4551,7 @@ fn whole_face_tool(
let segs = (0..4).filter_map(|i| Some(line(*corners.get(i)?, *corners.get((i + 1) % 4)?))).collect();
let slab = Region2 { outer: solidcraft_geom::Loop2 { segs }, holes: vec![] };
let (l, h) = if up { (lo, hi + reach) } else { (lo - reach, hi) };
let built = centered_extrude(&ss.plane, &slab, l, h, None).ok()?;
let built = centered_extrude(&ss.plane, &slab, l, h, None, None).ok()?;
let [built] = built.as_slice() else { return None };
#[cfg(test)]
whole_face_tests::slab_taken();
@@ -4561,7 +4562,14 @@ fn whole_face_tool(
}
/// Construct planar caps locally before placing the completed tool.
fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketch: Option<&SolvedSketch>) -> Result<Vec<Traced>> {
fn centered_extrude(
plane: &Plane,
region: &Region2,
start: f64,
end: f64,
sketch: Option<&SolvedSketch>,
thin: Option<&crate::lineage::ThinWallRoles>,
) -> Result<Vec<Traced>> {
if !plane.origin.is_finite()
|| !plane.x.is_finite()
|| !plane.y.is_finite()
@@ -4577,6 +4585,7 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
sketch,
region: Some(region),
caps: [Some(crate::lineage::extrude_cap_role(lo)), Some(crate::lineage::extrude_cap_role(hi))],
thin,
};
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
// wire can tilt an otherwise exact planar cap through numeric cancellation.
@@ -4601,11 +4610,8 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
.collect()
}
/// The regions a thin extrude's wall covers in its sketch.
pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result<Vec<Region2>> {
if thin.curves.is_empty() || thin.curves.len() > 1000 {
return Err(DocError::Invalid("a thin extrude needs 1…1000 sketch curves".into()));
}
/// How far a thin extrude's wall reaches either side of its curves: (inside, outside).
fn thin_distances(vals: &BTreeMap<String, Value>, thin: &crate::ThinWall) -> Result<(f64, f64)> {
let t = val(vals, &thin.thickness, Kind::Length)?;
let (inside, outside) = match thin.side {
crate::ThinSide::Center => (t / 2.0, t / 2.0),
@@ -4619,6 +4625,15 @@ pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, th
if !(inside >= 0.0 && outside >= 0.0 && inside + outside > 0.0) {
return Err(DocError::Invalid("the wall thickness must be positive".into()));
}
Ok((inside, outside))
}
/// The regions a thin extrude's wall covers in its sketch.
pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result<Vec<Region2>> {
if thin.curves.is_empty() || thin.curves.len() > 1000 {
return Err(DocError::Invalid("a thin extrude needs 1…1000 sketch curves".into()));
}
let (inside, outside) = thin_distances(vals, thin)?;
let curves = thin
.curves
.iter()
@@ -4653,6 +4668,14 @@ fn feature_tools_with_constructor(
Some(thin) => thin_regions(vals, &ss, thin)?,
None => solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?),
};
// A thin wall's sides are named by the curves they run beside.
let thin_roles = match thin {
Some(thin) => {
let (inside, outside) = thin_distances(vals, thin)?;
Some(crate::lineage::ThinWallRoles { curves: thin.curves.clone(), distances: [inside, outside] })
}
None => None,
};
// From / To Object: where the face or point lies along the normal, measured over the
// middle of the profiles. The points are in this feature's component frame, so the
// model is seen from there: a target in another, moved component where it is shown.
@@ -4727,7 +4750,8 @@ fn feature_tools_with_constructor(
// sides standing on a face are curved where they meet it (along text, say),
// and the boolean finds no result there. The sides keep their slope, so
// the profile stays the section at the start.
let roles = crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() };
let roles =
crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone(), thin: thin_roles.as_ref() };
// A start that is a whole face of the target (a foot under a part's base) is sewn on
// along it, as it lies: run on, the sides would leave the face through its edges.
if on_face
@@ -4850,7 +4874,7 @@ fn feature_tools_with_constructor(
{
let exact = regions
.iter()
.map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss)))
.map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss), thin_roles.as_ref()))
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
@@ -4899,7 +4923,7 @@ fn feature_tools_with_constructor(
continue;
}
if *operation == Operation::Join {
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss), thin_roles.as_ref())?;
if exact.iter().all(|tool| {
st.bodies
.iter()
@@ -4933,14 +4957,14 @@ fn feature_tools_with_constructor(
let mut r = filled(grown(&|pieces| {
surroundings.as_ref().is_some_and(|sur| sur.harmless(&ss.plane, pieces, -sur.skin, l2 + sur.skin, h2 - sur.skin))
}));
let mut built = centered_extrude(&ss.plane, &r, l2, h2, Some(&ss));
let mut built = centered_extrude(&ss.plane, &r, l2, h2, Some(&ss), thin_roles.as_ref());
if built.is_err() {
// A profile the kernel cannot build as drawn (a square some hundredths of a
// micrometre across, away from the origin, defeats truck's plane fit) grows
// wherever the samples find room, as it used to.
let loose = filled(grown(&|_| true));
if loose != r {
built = centered_extrude(&ss.plane, &loose, l2, h2, Some(&ss));
built = centered_extrude(&ss.plane, &loose, l2, h2, Some(&ss), thin_roles.as_ref());
r = loose;
}
}
@@ -8168,7 +8192,7 @@ fn split_by_plane_traced(input: &Traced, plane: &Plane) -> Result<Vec<Traced>> {
// The box's cap in the plane: its start cap on the positive side, its end cap on the other.
let section = Some("section".to_string());
let caps = if positive { [section, None] } else { [None, section] };
let tool = crate::lineage::ProfileRoles { sketch: None, region: None, caps }.tool(tool, None);
let tool = crate::lineage::ProfileRoles { sketch: None, region: None, caps, thin: None }.tool(tool, None);
parts.push(Traced::step(part, &[input, &tool]));
}
Ok(parts)
+17 -2
View File
@@ -193,6 +193,16 @@ pub(crate) struct ProfileRoles<'a> {
pub region: Option<&'a Region2>,
/// The start and end caps' roles (`None`: not known).
pub caps: [Option<String>; 2],
/// A thin wall: its sides lie off the sketch curves, and are named by the curve they run
/// beside.
pub thin: Option<&'a ThinWallRoles>,
}
/// What names the sides of a thin extrude's wall: the curves it follows and how far its sides lie
/// from them (the thickness either side).
pub(crate) struct ThinWallRoles {
pub curves: Vec<String>,
pub distances: [f64; 2],
}
impl<'a> ProfileRoles<'a> {
@@ -210,7 +220,12 @@ impl<'a> ProfileRoles<'a> {
// Every curve the segment is drawn on (one, or several coincident or collinear ones).
let curve = |segment: usize| -> Vec<String> {
let (Some(curves), Some(segment)) = (curves.as_ref(), segments.get(segment)) else { return Vec::new() };
curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect()
let drawn: Vec<String> = curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect();
match self.thin {
// A thin wall's side beside its curve (the other side may lie on it).
Some(thin) if drawn.is_empty() => curves.find_offset(segment, &thin.curves, thin.distances),
_ => drawn,
}
};
let roles = history
.faces
@@ -242,7 +257,7 @@ impl<'a> ProfileRoles<'a> {
/// A body swept from a profile (revolve, sweep, loft): its sides named by the sketch curves,
/// its caps `start` and `end`.
pub fn swept(sketch: Option<&'a SolvedSketch>, region: Option<&'a Region2>) -> ProfileRoles<'a> {
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())] }
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())], thin: None }
}
/// A tool built from the profile (and then moved, from `built`, when given), its faces named
@@ -282,3 +282,99 @@ fn no_errors(s: &Session, context: &str) {
s.model.results.iter().filter_map(|r| r.error.as_ref()).collect::<Vec<_>>()
);
}
/// How far a face's plane lies from the origin along its own normal.
fn plane_offset(plane: &solidcraft_geom::Plane) -> f64 {
plane.normal().dot(plane.origin)
}
#[test]
fn the_walls_of_an_open_thin_extrude_take_sketches_that_follow_their_thickness() {
// Three lines make an open wall; its sides lie off the sketch lines (a hair either side of
// each), so no sketch curve is under them. Each side still has a name its history can tell
// apart, so a sketch on it attaches and follows the wall when the thickness changes.
for side in ["center", "inside", "outside"] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
let lines = run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 30], [0, 30]], "closed": false}));
run(&mut s, "sketch.finish", json!({}));
let curves = lines["created"]["curves"].clone();
let thin = |t: &str| json!({"feature": "Wall", "set": {"thin": {"curves": curves, "thickness": t, "side": side}}});
run(
&mut s,
"solid.extrude",
json!({"sketch": "Outline", "thin": true, "curves": curves, "thickness": "2", "side": side, "distance": 20, "name": "Wall"}),
);
no_errors(&s, side);
let picks = flat_side_faces(&s);
assert!(picks.len() >= 6, "{side}: {picks:?}");
let mut moved_by_one = 0;
for (k, (label, at)) in picks.iter().enumerate() {
let name = format!("S{k}");
let mut t = s.scratch();
sketch_on(&mut t, *at, &name);
let (plane, _) = attached(&t, &name);
// Twice as thick, then back: the sketch stays on its face.
run(&mut t, "timeline.edit", thin("4"));
no_errors(&t, &format!("{side} {label}"));
let (thick, _) = attached(&t, &name);
if side == "center" {
// A side of a centred wall is half the thickness off its line: 1 -> 2 mm. The
// wall's two ends stay where they are.
let moved = (plane_offset(&thick).abs() - plane_offset(&plane).abs()).abs();
assert!(moved < 1e-6 || (moved - 1.0).abs() < 1e-6, "{label}: moved {moved}");
moved_by_one += usize::from(moved > 0.5);
}
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&t.doc.to_json()).unwrap().into(), ..Session::default() };
reopened.refresh();
no_errors(&reopened, side);
same_plane(&attached(&reopened, &name).0, &thick);
}
if side == "center" {
assert_eq!(moved_by_one, 6, "two sides of each of the three lines move with the thickness");
}
}
}
/// A point inside each flat, vertical face of the first body: (what it is, point).
fn flat_side_faces(s: &Session) -> Vec<(String, [f64; 3])> {
let world = s.world_state();
let mut out = Vec::new();
let Some(body) = world.bodies.first() else { return out };
let mesh = body.mesh();
let mut best: std::collections::BTreeMap<usize, (solidcraft_geom::Vec3, f64)> = std::collections::BTreeMap::new();
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
if let Some([a, b, c]) = mesh.tri(triangle) {
let area = (b - a).cross(c - a).len();
let entry = best.entry(*face as usize).or_insert(((a + b + c) * (1. / 3.), -1.));
if area > entry.1 {
*entry = ((a + b + c) * (1. / 3.), area);
}
}
}
for (index, (point, _)) in best {
if let Some(plane) = body.body.planar_face(index)
&& plane.normal().z.abs() < 1e-6
{
out.push((format!("face {index} normal {:?}", plane.normal()), [point.x, point.y, point.z]));
}
}
out
}
#[test]
fn the_round_sides_of_a_thin_ring_are_named_by_the_circle_they_run_beside() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 5, "name": "Ring"}));
let world = s.world_state();
let names = solidcraft_doc::naming::face_names(world.bodies.first().unwrap());
// Each round side is two half cylinders, told apart by their numbers.
let walls: Vec<_> = names.iter().filter(|name| name.contains(":wall:")).collect();
assert_eq!(walls.len(), 4, "{names:?}");
for side in ["left", "right"] {
assert_eq!(walls.iter().filter(|name| name.contains(&format!(":wall:c1:{side}#"))).count(), 2, "{walls:?}");
}
}