From 4a70c03debf6b4347dc5be9b735264c1a8252050 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:38:41 +0200 Subject: [PATCH] Revolve: a profile that comes back to the axis along a curve revolves right, and cuts with it work (#1853) (#1866) Thanks @SychPL. --- crates/engine/src/boolean_regression_tests.rs | 35 +++ crates/kernel/src/build.rs | 270 +++++++++++++----- crates/kernel/tests/revolve_on_axis.rs | 86 ++++++ tests/robustness/goldens.json | 7 +- 4 files changed, 317 insertions(+), 81 deletions(-) create mode 100644 crates/kernel/tests/revolve_on_axis.rs diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 911c9220..6b7d8151 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -534,6 +534,41 @@ fn box_intersect_splits_the_end_off_a_raked_wedge_after_its_end_rounds() { } } +/// #1853: half a slot drawn on a plate's top face, cut along its centreline, revolved about a +/// construction line on that centreline as a cut. Half a turn into the plate and a full turn +/// both failed with "no result": the revolve came out wrong where its arcs end on the axis (a +/// fifth too much or too little), and the cut could not intersect it there. Both now take the +/// half of the capsule below the face. +#[test] +fn revolve_cut_of_a_half_slot_about_its_centreline_in_the_face() { + let pi = std::f64::consts::PI; + let inside = (pi * 9.0 * 14.0 + 4.0 / 3.0 * pi * 27.0) / 2.0; + for angle in ["-180 deg", "360 deg"] { + let mut s = Session::default(); + let steps = [ + ("solid.box", json!({"corner": [0, 0, 0], "length": 40, "width": 20, "height": 10, "body_name": "Plate"})), + ("sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Half"})), + ("sketch.line", json!({"points": [[10, 10], [30, 10]]})), + ("sketch.line", json!({"points": [[27, 13], [13, 13]]})), + ("sketch.arc.center_point", json!({"center": [13, 10], "start": [13, 13], "end": [10, 10]})), + ("sketch.arc.center_point", json!({"center": [27, 10], "start": [30, 10], "end": [27, 13]})), + ("sketch.line", json!({"points": [[5, 10], [35, 10]], "construction": true, "ids": ["axis"]})), + ("sketch.finish", json!({})), + ]; + for (command, params) in steps { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")); + } + let whole = volume(&s, "Plate"); + s.execute("solid.revolve", &json!({"sketch": "Half", "axis": "axis", "angle": angle, "operation": "cut"})) + .unwrap_or_else(|e| panic!("revolve {angle}: {e}")); + let bad = s.model.state().body("Plate").map(|b| b.body.validity()).unwrap_or_default(); + assert!(bad.is_empty(), "{angle}: {bad:?}"); + let removed = whole - volume(&s, "Plate"); + println!("#1853 revolve {angle}: removed {removed:.6} mm³ of the {inside:.6} below the face"); + assert!((removed - inside).abs() < 1e-3 * inside, "{angle}: removed {removed} instead of {inside}"); + } +} + /// #2005: changing a circular sweep from a separate body to a half-turn cut must create the /// internal channel, rather than failing the Boolean on coincident end caps. #[test] diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 1548897f..c6f1d12e 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -417,6 +417,18 @@ fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?); continue; } + // A full turn of a profile that comes back to the axis along a curve (a half slot about + // its centreline, a dome at the far end): truck's cone gives the faces + // swept from it the wrong region (their pole is at the end of their curve), and cuts with + // such a body failed (#1853). It is built as part turns are, a third of a turn at a time. + if angle.abs() >= std::f64::consts::TAU - 1e-9 + && r.holes.is_empty() + && pole_at_end(&r.outer, axis_origin, d) + && let Some(b) = revolve_on_axis(plane, &given.outer, axis_origin, d, angle)? + { + out.push(b); + continue; + } // A full revolution of a profile with one edge on the axis: sweep the rest of the loop // as a cone-like shell (no zero-area faces, which booleans can't handle). if angle.abs() >= std::f64::consts::TAU - 1e-9 @@ -436,7 +448,7 @@ fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec let angle = if toward < 0.0 { -angle } else { angle }; // A profile with an edge on the axis: no zero-area face where that edge turns on itself. if r.holes.is_empty() - && let Some(b) = revolve_partial_on_axis(plane, &given.outer, axis_origin, d, angle)? + && let Some(b) = revolve_on_axis(plane, &given.outer, axis_origin, d, angle)? { out.push(b); continue; @@ -1285,11 +1297,47 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> { (same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r)) } -/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces, -/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the -/// loop isn't like that. -fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result> { - use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed}; +/// Whether the loop has one edge on the axis and comes back to it along a curve (the segment +/// before the axis edge is not a line). A line there (a drill point) sweeps a cone, which +/// truck's cone builds right. +fn pole_at_end(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> bool { + let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; + let n = lp.segs.len(); + let axis_segs: Vec = + (0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect(); + let [k] = axis_segs[..] else { return false }; + lp.segs.get((k + n - 1) % n).is_some_and(|s| !on_axis(s.start()) && !matches!(s, Seg2::Line { .. })) +} + +/// The edge from `a` to `b` along the profile segment `sg` (in `plane`), for a revolve's sweep. +/// One that ends on the axis (`ends_on_axis`) is built from there and turned round, so that the +/// surface swept from its curve has its pole at the curve's start. truck's revolved surface +/// reads a point on its axis at the start of its curve with the angle of the point before it, +/// but one at the end with an angle of 2π when it has no hint, past a part turn's span: the face +/// came out as another region (#1853). +fn swept_edge(plane: &Plane, sg: &Seg2, a: &mt::Vertex, b: &mt::Vertex, ends_on_axis: bool) -> mt::Edge { + if ends_on_axis { + return swept_edge(plane, &sg.reversed(), b, a, false).inverse(); + } + match *sg { + Seg2::Line { .. } => builder::line(a, b), + Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))), + Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => { + crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b)) + } + } +} + +/// Where [`revolve_on_axis`]'s full turns start, from the profile: an angle no plane a part is +/// commonly cut along lies at (as full circles start, see `reseam`). +const FULL_TURN_START: f64 = 0.4142; + +/// A revolve of a loop with one edge on the axis: the other edges sweep their faces, a third of a +/// turn at most each. A partial turn is closed by two caps (the profile at the start and at the +/// end), which share the axis edge; a full turn ends where it started. `None` when the loop +/// isn't like that. +fn revolve_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result> { + use mt::{BoundedCurve, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed}; let lp = &numbered.lp; let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; let n = lp.segs.len(); @@ -1301,102 +1349,171 @@ fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: if chain.is_empty() || chain.iter().skip(1).any(|s| on_axis(s.start())) || chain.iter().any(|s| on_axis(s.mid())) { return Ok(None); } + let full = angle.abs() >= std::f64::consts::TAU - 1e-9; + // Faces of a third of a turn at most, as truck sweeps a full turn: a boolean could not + // intersect a half turn's faces and the half circles bounding them (a half slot turned half a + // turn into a plate, #1853), and a sphere's face needs room for its poles (below). + let pieces = (angle.abs() / (std::f64::consts::TAU / 3.0) - 1e-9).ceil().max(1.0) as usize; + let step = angle / pieces as f64; // Built with the axis through the world origin, then moved into place. let shift = plane.to_world(axis_origin); let local = Plane { origin: plane.origin - shift, ..*plane }; let plane = &local; let axis = plane.dir_to_world(d); - let rot = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle)); - let half = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle * 0.5)); + let turned = |by: f64| mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(by)); let turn = |m: &mt::Matrix4, p: Vec3| from_p3(mt::EuclideanSpace::from_vec((m * p3(p).to_homogeneous()).truncate())); guard("revolve", || { let fail = |m: &str| KernelError::Failed(format!("revolve: {m}")); let pts: Vec = chain.iter().map(|s| plane.to_world(s.start())).chain(chain.last().map(|s| plane.to_world(s.end()))).collect(); let last = pts.len() - 1; let pv: Vec = pts.iter().map(|p| builder::vertex(p3(*p))).collect(); - // The ends stay where they are (on the axis). - let qv: Vec = pts - .iter() - .enumerate() - .map(|(i, p)| { - if i == 0 || i == last { pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p))) } else { builder::vertex(p3(turn(&rot, *p))) } - }) - .collect(); let mut e: Vec = Vec::new(); - let mut er: Vec = Vec::new(); for (i, sg) in chain.iter().enumerate() { - let (Some(a), Some(b), Some(qa), Some(qb)) = (pv.get(i), pv.get(i + 1), qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) }; - let edge = match *sg { - Seg2::Line { .. } => builder::line(a, b), - Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))), - Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => { - crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b)) - } - }; - let curve = edge.oriented_curve().transformed(rot); - er.push(mt::Edge::new(qa, qb, curve)); - e.push(edge); + let (Some(a), Some(b)) = (pv.get(i), pv.get(i + 1)) else { return Err(fail("chain")) }; + e.push(swept_edge(plane, sg, a, b, on_axis(sg.end()))); } - // Arcs swept by the vertices off the axis. - let mut arcs: Vec> = vec![None; pts.len()]; - for i in 1..last { - let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue }; - if let Some(slot) = arcs.get_mut(i) { - *slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p)))); + // The profile's vertices and edges where each piece starts and ends: turned by the step, + // the ends staying where they are (on the axis). A part turn starts on the profile. A full + // turn starts at an odd angle and ends where it started: the profile lies in the plane + // it was drawn on, often a face of the body cut, and a seam lying in that face as well + // left the cut nothing it could intersect there (#1853). + let start = if full { FULL_TURN_START } else { 0.0 }; + let mut levels: Vec<(Vec, Vec)> = Vec::new(); + for j in 0..=pieces { + if j == 0 && !full { + levels.push((pv.clone(), e.clone())); + continue; } + if let (true, Some(first)) = (j == pieces && full, levels.first()) { + levels.push(first.clone()); + continue; + } + let rot = turned(start + step * j as f64); + let qv: Vec = pts + .iter() + .enumerate() + .map(|(i, p)| { + if i == 0 || i == last { + pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p))) + } else { + builder::vertex(p3(turn(&rot, *p))) + } + }) + .collect(); + let mut er: Vec = Vec::new(); + for (i, edge) in e.iter().enumerate() { + let (Some(qa), Some(qb)) = (qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) }; + let curve = edge.curve().transformed(rot); + er.push(if edge.orientation() { mt::Edge::new(qa, qb, curve) } else { mt::Edge::new(qb, qa, curve).inverse() }); + } + levels.push((qv, er)); } // Side faces: each edge's swept surface, oriented to agree with its boundary, from the // profile segment the edge was built from. let mut faces: Vec<(mt::Face, FaceProv)> = Vec::new(); - for (i, sg) in chain.iter().enumerate() { - let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) }; - let mut ws: Vec = vec![ei.clone()]; - if let Some(Some(c)) = arcs.get(i + 1) { - ws.push(c.clone()); + for (j, piece) in levels.windows(2).enumerate() { + let [(pv, e), (qv, er)] = piece else { return Err(fail("pieces")) }; + let half = turned(start + step * (j as f64 + 0.5)); + // Arcs swept by the vertices off the axis. + let mut arcs: Vec> = vec![None; pts.len()]; + for i in 1..last { + let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue }; + if let Some(slot) = arcs.get_mut(i) { + *slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p)))); + } } - ws.push(eri.inverse()); - if let Some(Some(c)) = arcs.get(i) { - ws.push(c.inverse()); - } - let wire: mt::Wire = ws.into(); - let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9)); - let f = if radial { - builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))? - } else { - let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(axis), mt::Rad(angle)); - let mut surface = template.face_iter().next().map(|f| f.oriented_surface()).ok_or_else(|| fail("swept face"))?; - // The loop's turning (Newell) against the surface normal halfway round. - let mut nw = Vec3::ZERO; - let poly: Vec = wire - .edge_iter() - .flat_map(|ed| { - let c = ed.oriented_curve(); - let (t0, t1) = c.range_tuple(); - (0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::>() - }) - .collect(); - for (j, p) in poly.iter().enumerate() { - if let Some(q) = poly.get((j + 1) % poly.len()) { - nw += p.cross(*q); + for (i, sg) in chain.iter().enumerate() { + let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) }; + let mut ws: Vec = vec![ei.clone()]; + if let Some(Some(c)) = arcs.get(i + 1) { + ws.push(c.clone()); + } + ws.push(eri.inverse()); + if let Some(Some(c)) = arcs.get(i) { + ws.push(c.inverse()); + } + let wire: mt::Wire = ws.into(); + let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9)); + let f = if radial { + builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))? + } else { + // The surface turns from the piece's start through |step|, so its angle runs + // from 0 across the face. Inverting it turns its axis round, which puts the + // face at 2π - |step| to 2π: its start then lies on the angle's seam, where + // points (and a pole without a hint) read as 0, the far side, and the face + // meshed and intersected as the wrong region (#1853). The face is turned + // round instead. + let (dir, by) = if step < 0.0 { (-axis, -step) } else { (axis, step) }; + let surface = match *sg { + // An arc about a centre on the axis that reaches it sweeps part of a sphere + // with its pole there, and a boolean can't follow an intersection into a + // pole: a cut by the plane through the axis (the face the profile was drawn + // on) failed (#1853). The face lies on the sphere turned about the axis + // square to the revolve's and to the face's middle, as `sphere_solid`'s + // patches do: its poles are a quarter turn from that middle, past the + // face's third of a turn, and its seam runs round the back. + Seg2::Arc { center, radius, .. } if on_axis(center) && (on_axis(sg.start()) || on_axis(sg.end())) => { + let c = plane.to_world(center); + let out = turn(&half, plane.to_world(sg.mid())) - c; + let out = (out - axis * out.dot(axis)).normalized().ok_or_else(|| fail("sphere"))?; + let pole = axis.cross(out); + let meridian = builder::circle_arc( + &builder::vertex(p3(c + pole * radius)), + &builder::vertex(p3(c - pole * radius)), + p3(c - out * radius), + ); + mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution( + meridian.oriented_curve(), + p3(c), + v3(pole), + ))) + } + _ => { + let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(dir), mt::Rad(by)); + template.face_iter().next().map(|f| f.surface()).ok_or_else(|| fail("swept face"))? + } + }; + // The loop's turning (Newell) against the surface normal halfway round. + let mut nw = Vec3::ZERO; + let poly: Vec = wire + .edge_iter() + .flat_map(|ed| { + let c = ed.oriented_curve(); + let (t0, t1) = c.range_tuple(); + (0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::>() + }) + .collect(); + for (j, p) in poly.iter().enumerate() { + if let Some(q) = poly.get((j + 1) % poly.len()) { + nw += p.cross(*q); + } } - } - let probe = turn(&half, plane.to_world(sg.mid())); - let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v)); - if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) { - surface = surface.inverse(); - } - mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))? - }; - faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n)))); + let probe = turn(&half, plane.to_world(sg.mid())); + let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v)); + if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) { + mt::Face::try_new(vec![wire.inverse()], surface).map_err(|er| fail(&er.to_string()))?.inverse() + } else { + mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))? + } + }; + faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n)))); + } + } + let (faces, mut history): (Vec, Vec) = faces.into_iter().unzip(); + let close = |solid: Solid, history: Vec| { + let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid }; + record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some) + }; + if full { + return close(Solid::try_new(vec![faces.into()]).map_err(|er| fail(&er.to_string()))?, history); } // Caps: the profile at the start and at the end, sharing the axis edge. - let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) }; + let ((Some(a0), Some(an)), Some((_, er))) = ((pv.first(), pv.get(last)), levels.last()) else { return Err(fail("ends")) }; let axis_edge = builder::line(an, a0); let start: mt::Wire = e.iter().rev().map(|x| x.inverse()).chain(std::iter::once(axis_edge.inverse())).collect::>().into(); let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::>().into(); let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?; let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?; - let (faces, mut history): (Vec, Vec) = faces.into_iter().unzip(); history.extend([record::cap(false), record::cap(true)]); let mut last_err = String::new(); for flip in 0..4 { @@ -1404,10 +1521,7 @@ fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: all.push(if flip & 1 == 0 { cs.clone() } else { cs.inverse() }); all.push(if flip & 2 == 0 { ce.clone() } else { ce.inverse() }); match Solid::try_new(vec![all.into()]) { - Ok(solid) => { - let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid }; - return record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some); - } + Ok(solid) => return close(solid, history), Err(er) => last_err = er.to_string(), } } diff --git a/crates/kernel/tests/revolve_on_axis.rs b/crates/kernel/tests/revolve_on_axis.rs new file mode 100644 index 00000000..c9439d82 --- /dev/null +++ b/crates/kernel/tests/revolve_on_axis.rs @@ -0,0 +1,86 @@ +//! Revolves of profiles with an edge on the axis that come back to it along a curve or a line +//! (#1853): a half slot about its centreline, a quarter disc and a bullet whose arc ends on the +//! axis, a drill point whose tip does. Their volumes are analytic, and a half slot revolved about +//! a centreline lying in a face of a plate, as a cut, takes what it should. + +use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; +use solidcraft_kernel::{BoolOp, boolean, box_solid, measure, revolve}; +use std::f64::consts::{FRAC_PI_2, PI, TAU}; + +const R: f64 = 3.0; + +fn line(a: (f64, f64), b: (f64, f64)) -> Seg2 { + Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) } +} + +/// A quarter of a circle of radius `R` about `c`, counter-clockwise from `start`. +fn quarter(c: (f64, f64), start: f64) -> Seg2 { + Seg2::Arc { center: Vec2::new(c.0, c.1), radius: R, start, sweep: FRAC_PI_2 } +} + +fn region(segs: Vec) -> Region2 { + Region2 { outer: Loop2 { segs }, holes: vec![] } +} + +/// Half a slot (radius `R`, centres 14 apart) cut along its centreline on the x axis. +fn half_slot() -> Region2 { + region(vec![line((0.0, 0.0), (20.0, 0.0)), quarter((17.0, 0.0), 0.0), line((17.0, R), (3.0, R)), quarter((3.0, 0.0), FRAC_PI_2)]) +} + +#[test] +fn revolves_that_end_on_the_axis_along_a_curve_have_their_volume() { + let sphere = 4.0 / 3.0 * PI * R.powi(3); + let cylinder = PI * R * R * 14.0; + let shapes = [ + ("half slot", half_slot(), cylinder + sphere), + ("quarter disc", region(vec![line((-R, 0.0), (0.0, 0.0)), line((0.0, 0.0), (0.0, R)), quarter((0.0, 0.0), FRAC_PI_2)]), sphere / 2.0), + ( + "bullet", + region(vec![line((0.0, 0.0), (17.0, 0.0)), line((17.0, 0.0), (17.0, R)), line((17.0, R), (3.0, R)), quarter((3.0, 0.0), FRAC_PI_2)]), + cylinder + sphere / 2.0, + ), + // A drill point: a line coming back to the axis (a full turn keeps truck's cone). + ( + "drill point", + region(vec![line((0.0, 0.0), (20.0, 0.0)), line((20.0, 0.0), (20.0, R)), line((20.0, R), (3.0, R)), line((3.0, R), (0.0, 0.0))]), + PI * R * R * 17.0 + PI * R * R * 3.0 / 3.0, + ), + ]; + // The plane through the origin, and one moved and turned away from it. + let planes = [Plane::XY, Plane { origin: Vec3::new(5.0, 7.0, 10.0), x: Vec3::Y, y: Vec3::Z }]; + for (name, r, full) in &shapes { + for plane in &planes { + for turn in [FRAC_PI_2, PI, -PI, TAU] { + let body = revolve(plane, std::slice::from_ref(r), Vec2::ZERO, Vec2::new(1.0, 0.0), turn).unwrap().pop().unwrap(); + assert!(body.validity().is_empty(), "{name}, {turn}: {:?}", body.validity()); + let want = full * turn.abs() / TAU; + let got = measure(&body).unwrap().volume; + assert!((got - want).abs() < 1e-3 * want, "{name} turned {turn} on {:?}: {got} instead of {want}", plane.origin); + } + } + } +} + +#[test] +fn a_half_slot_revolved_about_a_centreline_in_a_face_cuts_it() { + let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 20.0, 10.0)).unwrap(); + let whole = measure(&plate).unwrap().volume; + // The half slot from x = 10 to 30 with its centreline on y = 10 of the top face; the plate + // holds the half of the capsule below it. + let inside = (PI * R * R * 14.0 + 4.0 / 3.0 * PI * R.powi(3)) / 2.0; + let r = half_slot(); + // The sketch plane facing out of the plate and into it (its y running the other way). + for flip in [1.0, -1.0] { + let plane = Plane { origin: Vec3::new(10.0, 10.0, 10.0), x: Vec3::X, y: Vec3::Y * flip }; + for turn in [PI, -PI, TAU] { + let tool = revolve(&plane, std::slice::from_ref(&r), Vec2::ZERO, Vec2::new(1.0, 0.0), turn).unwrap().pop().unwrap(); + // A half turn into the plate, or a full one, cuts a groove; a half turn out of it misses. + let into = turn.abs() > PI || tool.tessellate(0.01).unwrap().bounds().min.z < 9.0; + let cut = boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap(); + assert!(cut.validity().is_empty(), "flip {flip}, {turn}: {:?}", cut.validity()); + let removed = whole - measure(&cut).unwrap().volume; + let want = if into { inside } else { 0.0 }; + assert!((removed - want).abs() < 1e-3 * inside, "flip {flip}, turned {turn}: removed {removed} instead of {want}"); + } + } +} diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index ecfd5fba..2418a1cb 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -29,7 +29,7 @@ "25": {"area":7142.717684,"bodies":1,"edges":48,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","shell"],"unsupported":0,"volume":7012.754625}, "26": {"area":6461.702601,"bodies":1,"edges":56,"error":null,"faces":22,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","join box","cut cylinder","rectangular pattern","cut sphere"],"unsupported":0,"volume":21188.159601}, "27": {"area":11263.08174,"bodies":3,"edges":99,"error":null,"faces":42,"outcome":"ok","rejected":0,"shells":4,"steps":["box","chamfer","cut sphere","cut sphere","join box","circular pattern","hole","join box"],"unsupported":0,"volume":49451.079636}, - "28": {"area":2394.691384,"bodies":1,"edges":15,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","join cylinder"],"unsupported":0,"volume":5902.121375}, + "28": {"area":2394.667072,"bodies":1,"edges":20,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","join cylinder"],"unsupported":0,"volume":5902.111713}, "29": {"area":6965.599057,"bodies":1,"edges":74,"error":null,"faces":30,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","shell","join cylinder"],"unsupported":0,"volume":3977.885339}, "30": {"area":850.154862,"bodies":1,"edges":21,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer"],"unsupported":0,"volume":1327.098159}, "31": {"area":10221.945046,"bodies":3,"edges":68,"error":null,"faces":29,"outcome":"ok","rejected":2,"shells":3,"steps":["box","cut box","fillet","join box","circular pattern","cut box","circular pattern","hole"],"unsupported":0,"volume":56397.581359}, @@ -96,7 +96,7 @@ "92": {"area":2855.139217,"bodies":1,"edges":30,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer","cut sphere"],"unsupported":0,"volume":9814.30292}, "93": {"area":7266.404017,"bodies":7,"edges":111,"error":null,"faces":51,"outcome":"ok","rejected":0,"shells":7,"steps":["extruded profile","fillet","chamfer","join box","circular pattern","rectangular pattern","cut cylinder"],"unsupported":0,"volume":12734.393171}, "94": {"area":3480.70333,"bodies":1,"edges":61,"error":null,"faces":26,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856}, - "95": {"area":2425.131626,"bodies":1,"edges":19,"error":"boolean Cut: the operation failed: no result; the operation ","faces":9,"outcome":"failed","rejected":0,"shells":1,"steps":["revolved section","hole","fillet","cut cylinder","cut sphere"],"unsupported":0,"volume":7106.301922} + "95": {"area":2375.340299,"bodies":1,"edges":33,"error":null,"faces":12,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","hole","cut cylinder","cut sphere","circular pattern","cut sphere"],"unsupported":1,"volume":6704.220611} }, "notes": [ {"reason":"First record: what each seed builds on main at 1a0100cd","seeds":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95]}, @@ -134,6 +134,7 @@ {"reason":"Seed 20: trace conical cutters against earlier conical cuts at finer unshifted tolerances before coarse copies can leave unusable junctions. The circular pattern and final box cut both build; replayed in the app with no feature errors, unchanged 45 faces/116 edges/one shell, no tool nudge, and volume/spatial guards retained. The finer intersection curves change measured volume by 0.020503 mm3 and area by 0.171629 mm2.","seeds":[20]}, {"reason":"After integrating #1718, replay the regenerated revolved-section recipes on the combined branch: seed 13 has 40 faces/101 edges (one face/two edges fewer), seed 94 has 27 faces/63 edges (one face/two edges more). Both complete fixed command sequences pass validity and volume-direction checks after every step, and two independent golden runs per seed agree exactly. Steps, outcomes, body and shell counts are unchanged; area and volume match within existing 1e-6 relative tolerances (seed 13 volume differs by 0.000002 mm3). Only topology counts are updated. The finer unshifted conical-cut route and its geometry guards remain in place.","seeds":[13,94]}, {"reason":"#1670 and #1753 (mesh export: twisted ruled faces divided to the chord tolerance, and the faces of an edge sharing its points exactly) change the meshes the kernel's validity and volume checks measure: seed 55's body (a filleted box cut by spheres and patterned) now passes the validity check it failed, and its volume moves by 1.3e-5 relative. Pull requests ran only every 8th seed, so main's full run found it. Recorded from main's CI output (Linux).","seeds":[55]}, - {"reason":"#853 recorded seeds 13, 71 and 94 as its own branch built them (13: 40 faces/101 edges, 71: 19/50, 94: 27/63), but with main's kernel they build as before (41/103, 20/52, 26/61), so every full run on main failed on them after #853 landed. This puts back the counts main builds; #853's conical-cut refinement (seed 20) is unchanged. Steps, outcomes, volumes and areas are the same.","seeds":[13,71,94]} + {"reason":"#853 recorded seeds 13, 71 and 94 as its own branch built them (13: 40 faces/101 edges, 71: 19/50, 94: 27/63), but with main's kernel they build as before (41/103, 20/52, 26/61), so every full run on main failed on them after #853 landed. This puts back the counts main builds; #853's conical-cut refinement (seed 20) is unchanged. Steps, outcomes, volumes and areas are the same.","seeds":[13,71,94]}, + {"reason":"#1853 (a part revolve of a profile with an edge on the axis is swept a third of a turn at most per face, so the boolean can intersect its faces): seeds 28 and 95 start with a rectangle on the axis turned half a turn, now two quarter turns. Seed 28 (joined by a cylinder) has more faces for the same shape (5902.1214 -> 5902.1117 mm3, the new faces' meshes). Seed 95's hole into it failed with 'boolean Cut: no result' on the half-turn faces and now builds, so the generator's later picks move. Values from Linux CI.","seeds":[28,95]} ] }