diff --git a/crates/doc/src/config_certificates.rs b/crates/doc/src/config_certificates.rs index da0e5d4ad4..f2080b01fa 100644 --- a/crates/doc/src/config_certificates.rs +++ b/crates/doc/src/config_certificates.rs @@ -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::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 { + 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 }) => { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index b8c7c2478e..bd842456cc 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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> { +fn centered_extrude( + plane: &Plane, + region: &Region2, + start: f64, + end: f64, + sketch: Option<&SolvedSketch>, + thin: Option<&crate::lineage::ThinWallRoles>, +) -> Result> { 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, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result> { - 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, 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, 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, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result> { + 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::>>()? .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> { // 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) diff --git a/crates/doc/src/lineage.rs b/crates/doc/src/lineage.rs index 3ae653095f..169d541e8a 100644 --- a/crates/doc/src/lineage.rs +++ b/crates/doc/src/lineage.rs @@ -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; 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, + 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 { 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 = 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 diff --git a/crates/engine/src/cmd/thin_extrude_tests.rs b/crates/engine/src/cmd/thin_extrude_tests.rs index 94f27e982c..86fd336e73 100644 --- a/crates/engine/src/cmd/thin_extrude_tests.rs +++ b/crates/engine/src/cmd/thin_extrude_tests.rs @@ -282,3 +282,99 @@ fn no_errors(s: &Session, context: &str) { s.model.results.iter().filter_map(|r| r.error.as_ref()).collect::>() ); } + +/// 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 = 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:?}"); + } +}