diff --git a/crates/kernel/src/pinch_tests.rs b/crates/kernel/src/pinch_tests.rs index 322c6569..7fd7331e 100644 --- a/crates/kernel/src/pinch_tests.rs +++ b/crates/kernel/src/pinch_tests.rs @@ -112,3 +112,96 @@ fn hole_touching_the_outline_stays_one_piece() { let res = boolean(&small, &big, BoolOp::Union).unwrap().unwrap(); assert_solid(&res, std::f64::consts::PI * (100.0 + 144.0) * 10.0, 1e-5, 1, "disc tangent inside a larger one"); } + +/// A Ø40 base with a Ø20 boss on it touching its rim from inside at (20, 0), and a Ø10 hole at +/// (15, 0) touching both rims there: the material on either side of the hole meets along the +/// line x = 20, y = 0 only (a zero-thickness curved wall). The hole cut through both, the boss +/// only and the base only, all exact (no tool nudge); then the same turned 30° and mirrored. +#[test] +fn hole_tangent_inside_a_stepped_boss_cuts_exactly() { + use std::f64::consts::PI; + for (turn, flip) in [(0.0, 1.0), (PI / 6.0, 1.0), (0.0, -1.0)] { + let at = |x: f64, y: f64| Vec2::new(x * turn.cos() - flip * y * turn.sin(), x * turn.sin() + flip * y * turn.cos()); + let circle = |x: f64, r: f64| Loop2::circle(at(x, 0.), r); + let base = prism(circle(0., 20.), 0., 5.); + let boss = prism(circle(10., 10.), 5., 15.); + let stepped = boolean(&base, &boss, BoolOp::Union).unwrap().unwrap(); + for (z0, z1, want) in [(-1., 20., 2000. + 1000. - 25. * 15.), (6., 20., 2000. + 1000. - 25. * 9.), (-1., 4., 2000. + 1000. - 25. * 4.)] { + let what = format!("hole z {z0}…{z1}, turned {turn}, flip {flip}"); + let hole = prism(circle(15., 5.), z0, z1); + let (res, d) = capture_boolean_diagnostics(|| boolean(&stepped, &hole, BoolOp::Cut)); + let res = res.unwrap_or_else(|e| panic!("{what}: {e}")).unwrap(); + assert_solid(&res, PI * want, 1e-5, 1, &what); + assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{what}: {d:?}"); + } + } +} + +/// A square hole in a stepped block whose side lies on the block's side: planar walls meeting +/// along a line at no angle are not a zero-thickness wall but one flat side, and a hole tangent +/// to the side of a square boss on a block cuts exactly. +#[test] +fn hole_tangent_to_a_flat_side_of_a_stepped_block_cuts_exactly() { + let block = prism(rect(0., 0., 40., 40.), 0., 5.); + let boss = prism(rect(10., 10., 40., 30.), 5., 15.); + let stepped = boolean(&block, &boss, BoolOp::Union).unwrap().unwrap(); + let hole = prism(disc(35., 20., 5.), -1., 20.); + let (res, d) = capture_boolean_diagnostics(|| boolean(&stepped, &hole, BoolOp::Cut)); + let res = res.unwrap().unwrap(); + assert_solid(&res, 8000. + 6000. - std::f64::consts::PI * 25. * 15., 1e-5, 1, "hole tangent to a flat side"); + assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}"); +} + +/// A stepped block cut by a disc tangent to its back side from inside leaves two posts meeting +/// only along the tangent line: the stepped prism route keeps them apart (its edge sheets bound +/// the posts, as in the single-height case above) rather than sewing one shell pinched along the +/// line. A diamond hole with a corner on the side of a stepped block is one piece: the material +/// goes round the hole. +#[test] +fn stepped_cuts_touching_along_a_line_keep_their_pieces() { + let block = prism(rect(0., 0., 31., 29.), 0., 10.); + let back = prism(rect(0., 20., 25., 29.), 10., 20.); + let stepped = boolean(&block, &back, BoolOp::Union).unwrap().unwrap(); + let tool = prism(disc(18.5, 4., 25.), -1., 21.); + let (res, d) = capture_boolean_diagnostics(|| boolean(&stepped, &tool, BoolOp::Cut)); + let res = res.unwrap().unwrap(); + let n = 400_000; + let dx = 31.0 / n as f64; + let post = |x: f64| (29.0 - (4.0 + (625.0 - (x - 18.5).powi(2)).max(0.0).sqrt()).min(29.0)).max(0.0); + let volume: f64 = (0..n) + .map(|i| { + let x = (i as f64 + 0.5) * dx; + post(x) * dx * if x < 25.0 { 20.0 } else { 10.0 } + }) + .sum(); + let v = res.tessellate(1e-4).unwrap().measure().volume; + assert!((v - volume).abs() <= volume * 1e-4, "posts: volume {v} vs {volume}"); + assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}"); + let sewn = crate::prism::prism_boolean(&stepped, &tool, BoolOp::Cut); + assert!(!matches!(&sewn, Some(Ok(Some(b))) if b.shell_count() < 2), "posts sewn into one shell"); + + let block = prism(rect(0., 0., 40., 40.), 0., 5.); + let boss = prism(rect(10., 10., 40., 30.), 5., 15.); + let stepped = boolean(&block, &boss, BoolOp::Union).unwrap().unwrap(); + let diamond = prism(poly(&[[30., 20.], [35., 15.], [40., 20.], [35., 25.]]), -1., 20.); + let (res, d) = capture_boolean_diagnostics(|| boolean(&stepped, &diamond, BoolOp::Cut)); + let res = res.unwrap().unwrap(); + assert_solid(&res, 8000. + 6000. - 50. * 15., 1e-9, 1, "diamond hole with a corner on a stepped side"); + assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}"); +} + +/// A row of holes each touching the next (a 5 mm pattern of Ø5 holes) through a stepped plate: +/// the walls between them have no thickness, and the cut is exact. +#[test] +fn row_of_touching_holes_through_a_stepped_plate_cuts_exactly() { + let plate = prism(rect(0., 0., 30., 35.), 0., 7.5); + let notch = prism(rect(0., 30., 22.5, 50.), 2.5, 25.); + let mut body = boolean(&plate, ¬ch, BoolOp::Cut).unwrap().unwrap(); + for x in [15.45, 20.45, 25.45] { + let hole = prism(disc(x, 15.7, 2.5), -5., 7.5); + let (res, d) = capture_boolean_diagnostics(|| boolean(&body, &hole, BoolOp::Cut)); + body = res.unwrap().unwrap(); + assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "hole at {x}: {d:?}"); + } + assert_solid(&body, 7312.5 - 3.0 * std::f64::consts::PI * 6.25 * 7.5, 1e-5, 1, "touching holes"); +} diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs index e7769a25..d3c247f3 100644 --- a/crates/kernel/src/sew.rs +++ b/crates/kernel/src/sew.rs @@ -185,66 +185,208 @@ pub(crate) fn sew_touching_with_sources(faces: &[FaceSpec], sources: &[Option Result> { - type Use = (EdgeUse, Vec3, Vec3, Vec3); - let mut uses: HashMap<(Key, Key), Vec> = HashMap::new(); +/// Below this angle (radians) two faces leave an edge the same way: tangent faces, ordered by +/// their curvature. +const SAME_WAY: f64 = 1e-6; + +/// Where more than two faces meet along a straight edge (bodies touching along a line, or a wall +/// of no thickness), which faces bound each sheet of it (each sheet gets its own edge), and the +/// vertex fans that follow from that (see [`corner_fans`]). Only edges on planes and on the walls +/// of extruded arcs are looked at. +/// +/// Each sheet bounds a wedge of material, so that pieces of material meeting along an edge stay +/// apart, except where that would make a face touch itself: a hole touching the outline from +/// inside (tangent, or at a corner) leaves its caps an outline and a hole meeting at the edge's +/// end, which with the material kept apart would share a vertex, and a face can't have that. +/// The material there goes round the hole, and the edges ending at such points get sheets that +/// bound the empty wedges instead: the hole's wall on either side is one sheet, the outline's +/// the other. +fn touching_sheets(faces: &[FaceSpec]) -> Result<(HashMap, HashMap)> { + let mut uses: HashMap<(Key, Key), Vec<(EdgeUse, Vec3, Vec3)>> = HashMap::new(); for (fi, face) in faces.iter().enumerate() { - let Some(outer) = face.loops.first() else { continue }; - let normal = outer - .iter() - .fold(Vec3::ZERO, |n, e| { - let (a, b) = e.ends(); - n + a.cross(b) - }) - .normalized(); + if !matches!(face.surface, SurfSpec::Plane | SurfSpec::Extruded { .. }) { + continue; + } for (li, lp) in face.loops.iter().enumerate() { for (ei, e) in lp.iter().enumerate() { - if matches!(e, EdgeSpec::Line { .. }) && matches!(face.surface, SurfSpec::Plane) { - let (a, b) = e.ends(); - let (ka, kb) = (key(a), key(b)); + if let EdgeSpec::Line { a, b } = e { + let (ka, kb) = (key(*a), key(*b)); let k = if ka < kb { (ka, kb) } else { (kb, ka) }; - if let Some(n) = normal { - uses.entry(k).or_default().push(((fi, li, ei), a, b, n)); - } + uses.entry(k).or_default().push(((fi, li, ei), *a, *b)); } } } } - let mut sheets = HashMap::new(); - for group in uses.values().filter(|g| g.len() > 2) { - if group.len() != 4 { - return Err(KernelError::Invalid("sew: unsupported edge contact".into())); - } - let mut paired = std::collections::HashSet::new(); - for (i, (use_a, a, b, n)) in group.iter().enumerate() { - if paired.contains(&i) { - continue; + // Each contact edge: its ends, both ways of pairing its faces, and whether the second is used. + let mut contacts = Vec::new(); + for (ends, group) in uses.iter().filter(|(_, g)| g.len() > 2) { + let [material, empty] = edge_sheets(faces, group)?; + contacts.push((*ends, material, empty, false)); + } + if contacts.is_empty() { + return Ok((HashMap::new(), corner_fans(faces, &HashMap::new()))); + } + let sheets_of = |contacts: &[((Key, Key), [[EdgeUse; 2]; 2], [[EdgeUse; 2]; 2], bool)]| -> HashMap { + let mut sheets = HashMap::new(); + for (_, material, empty, round) in contacts { + for (sheet, pair) in (if *round { empty } else { material }).iter().enumerate() { + for u in pair { + sheets.insert(*u, sheet); + } } - let t = (*b - *a).normalized().ok_or_else(|| KernelError::Invalid("sew: a degenerate contact".into()))?; - // Pair the two faces bounding each material wedge, not the empty - // wedge between them. Each pair uses the edge in opposite directions. - let j = group - .iter() - .enumerate() - .find_map(|(j, (_, c, d, m))| { - (i != j - && !paired.contains(&j) - && key(*a) == key(*d) - && key(*b) == key(*c) - && n.cross(t).dot(*m) < -1e-8 - && m.cross(-t).dot(*n) < -1e-8) - .then_some(j) - }) - .ok_or_else(|| KernelError::Invalid("sew: ambiguous edge contact".into()))?; - let use_b = group.get(j).ok_or_else(|| KernelError::Invalid("sew: edge contact".into()))?.0; - let sheet = paired.len() / 2; - sheets.insert(*use_a, sheet); - sheets.insert(use_b, sheet); - paired.insert(i); - paired.insert(j); + } + sheets + }; + // Each pass turns at least one edge round, and none back. + loop { + let sheets = sheets_of(&contacts); + let fans = corner_fans(faces, &sheets); + let touching = self_touching(faces, &fans); + let mut turned = false; + for (ends, _, _, round) in &mut contacts { + if !*round && (touching.contains(&ends.0) || touching.contains(&ends.1)) { + *round = true; + turned = true; + } + } + if !turned { + return Ok((sheets, fans)); } } - Ok(sheets) +} + +/// The points where a face has two corners at one vertex (its loops would touch there). +fn self_touching(faces: &[FaceSpec], fans: &HashMap) -> std::collections::HashSet { + let mut seen = std::collections::HashSet::new(); + let mut out = std::collections::HashSet::new(); + for (fi, f) in faces.iter().enumerate() { + for (li, l) in f.loops.iter().enumerate() { + for (ei, e) in l.iter().enumerate() { + let at = key(e.ends().0); + if !seen.insert((at, fans.get(&(fi, li, ei)).copied().unwrap_or(0), fi)) { + out.insert(at); + } + } + } + } + out +} + +/// The two ways of making sheets of an edge four faces meet along, as the pairs of faces +/// bounding each: around the wedges of material between them, and around the empty ones. +/// +/// Seen along the edge, each face leaves it as a curve; going round the edge, material and empty +/// wedges between them alternate. Faces are ordered by the way they leave the edge and, where +/// they leave it the same way (tangent faces), by how fast they turn from it: the order of +/// points a short way into each face, as in 2D loop chaining (`prism::tightest_left`). +fn edge_sheets(faces: &[FaceSpec], group: &[(EdgeUse, Vec3, Vec3)]) -> Result<[[[EdgeUse; 2]; 2]; 2]> { + let fail = |s: &str| KernelError::Invalid(format!("sew: {s}")); + let (Some((_, a0, b0)), 4) = (group.first(), group.len()) else { return Err(fail("unsupported edge contact")) }; + let t = (if key(*a0) < key(*b0) { *b0 - *a0 } else { *a0 - *b0 }).normalized().ok_or_else(|| fail("a degenerate contact"))?; + // Each face: its use, where the use starts, the way into it, its turn, its material side, and + // the angle of the way into it from the first face's. + let mut rays = Vec::with_capacity(4); + let mut reference = None; + for (u, a, b) in group { + let face = faces.get(u.0).ok_or_else(|| fail("edge contact"))?; + let (into, turn, ccw) = leaving(face, *a, *b, t).ok_or_else(|| fail("unsupported edge contact"))?; + let r: Vec3 = *reference.get_or_insert(into); + let angle = r.cross(into).dot(t).atan2(r.dot(into)).rem_euclid(std::f64::consts::TAU); + rays.push((*u, key(*a), turn, ccw, if angle > std::f64::consts::TAU - SAME_WAY { 0.0 } else { angle })); + } + rays.sort_by(|p, q| p.4.total_cmp(&q.4)); + // Faces leaving the same way, by their turn (a total order on each, which a comparison with + // a tolerance is not). + for i in 1..rays.len() { + let mut j = i; + while let (Some(p), Some(q)) = (rays.get(j.wrapping_sub(1)), rays.get(j)) { + if q.4 - p.4 > SAME_WAY || p.2 <= q.2 { + break; + } + rays.swap(j - 1, j); + j -= 1; + } + } + let wedges: Vec<_> = rays.iter().zip(rays.iter().cycle().skip(1)).collect(); + // Each wedge's faces must use the edge in opposite directions, and be told apart (faces + // leaving the same way and turning alike lie on each other). + let same_way = |p: f64, q: f64| { + let gap = (q - p).rem_euclid(std::f64::consts::TAU); + gap <= SAME_WAY || gap >= std::f64::consts::TAU - SAME_WAY + }; + let alike = |p: f64, q: f64| (p - q).abs() <= 1e-9 * p.abs().max(q.abs()).max(1e-9); + if wedges.iter().any(|(p, q)| p.3 == q.3 || p.1 == q.1 || (same_way(p.4, q.4) && alike(p.2, q.2))) { + return Err(fail("ambiguous edge contact")); + } + let pairs = |material: bool| -> Result<[[EdgeUse; 2]; 2]> { + let mut it = wedges.iter().filter(|(p, _)| p.3 == material).map(|(p, q)| [p.0, q.0]); + match (it.next(), it.next()) { + (Some(x), Some(y)) => Ok([x, y]), + _ => Err(fail("ambiguous edge contact")), + } + }; + Ok([pairs(true)?, pairs(false)?]) +} + +/// How a face leaves the straight edge `a`→`b` it bounds, seen along the edge's direction `t`: +/// the way into the face square to the edge, how fast it turns from that way (its curvature, +/// positive counter-clockwise about `t`; 0 for a plane), and whether its material is on its +/// counter-clockwise side. None for other faces, and for an extruded wall the edge is not a +/// ruling of. +fn leaving(face: &FaceSpec, a: Vec3, b: Vec3, t: Vec3) -> Option<(Vec3, f64, bool)> { + let along = (b - a).normalized()?; + // The outward normal along the edge, and the centre of curvature (with its curvature). + let (outward, centre) = match &face.surface { + SurfSpec::Plane => (plane_normal(face)?, None), + SurfSpec::Extruded { a: sa, b: sb, mid, dir } => { + let k = dir.normalized()?; + if along.dot(k).abs() < 1.0 - 1e-9 { + return None; + } + let centre = circumcentre(*sa, *mid, *sb)?; + let radial = |p: Vec3| { + let r = p - centre; + r - k * r.dot(k) + }; + let r = radial(a); + let (p, out) = face.probe?; + let side = if radial(p).dot(out) < 0.0 { -1.0 } else { 1.0 }; + (r.normalized()? * side, Some((centre, 1.0 / r.len()))) + } + _ => return None, + }; + // A face's loops run counter-clockwise about its outward normal: it lies on their left. + let into = outward.cross(along).normalized()?; + let left = t.cross(into); + let turn = match centre { + None => 0.0, + Some((c, curvature)) if (c - a).dot(left) > 0.0 => curvature, + Some((_, curvature)) => -curvature, + }; + turn.is_finite().then_some((into, turn, left.dot(outward) < 0.0)) +} + +/// The unit normal a planar face's outer loop runs counter-clockwise about (through its arcs' +/// midpoints and chains' control points, so that a loop of one line and one arc has one). +fn plane_normal(face: &FaceSpec) -> Option { + let n = face.loops.first()?.iter().fold(Vec3::ZERO, |n, e| match e { + EdgeSpec::Line { a, b } => n + a.cross(*b), + EdgeSpec::Arc { a, b, mid } => n + a.cross(*mid) + mid.cross(*b), + EdgeSpec::Chain { ctrl } => ctrl.iter().zip(ctrl.iter().skip(1)).fold(n, |n, (p, q)| n + p.cross(*q)), + }); + n.normalized() +} + +/// The centre of the circle through three points; None when they are in line. +fn circumcentre(a: Vec3, b: Vec3, c: Vec3) -> Option { + let (u, v) = (b - a, c - a); + let w = u.cross(v); + let d = 2.0 * w.dot(w); + if !(d > 1e-300) { + return None; + } + let p = a + (v.cross(w) * u.dot(u) + w.cross(u) * v.dot(v)) * (1.0 / d); + p.is_finite().then_some(p) } /// A loop corner: face, loop, and the edge of the loop that starts there. @@ -518,10 +660,9 @@ fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option = Vec::with_capacity(faces.len()); diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 0e810365..9777f92e 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -43,7 +43,7 @@ "39": {"area":1892.050815,"bodies":1,"edges":27,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","chamfer","cut sphere","rectangular pattern"],"unsupported":1,"volume":4040.62787}, "40": {"area":15548.516294,"bodies":1,"edges":49,"error":"offset: a corner whose faces don't meet the same way after t","faces":22,"outcome":"failed","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","cut cylinder","join cylinder","shell"],"unsupported":0,"volume":16543.777532}, "41": {"area":7611.393446,"bodies":1,"edges":99,"error":null,"faces":35,"outcome":"ok","rejected":1,"shells":1,"steps":["box","cut sphere","shell","fillet","cut cylinder","rectangular pattern","cut sphere","join cylinder"],"unsupported":1,"volume":5973.93328}, - "42": {"area":5486.881252,"bodies":1,"edges":45,"error":null,"faces":17,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut box","cut cylinder","rectangular pattern"],"unsupported":0,"volume":19095.110963}, + "42": {"area":5486.877622,"bodies":1,"edges":63,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut box","cut cylinder","rectangular pattern"],"unsupported":0,"volume":19095.106157}, "43": {"area":3492.988343,"bodies":1,"edges":72,"error":null,"faces":26,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","cut cylinder","rectangular pattern","fillet","hole","rectangular pattern","join cylinder"],"unsupported":1,"volume":7528.548742}, "44": {"area":7972.435565,"bodies":1,"edges":36,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","shell","cut sphere"],"unsupported":0,"volume":7622.184369}, "45": {"area":14416.876392,"bodies":2,"edges":62,"error":"boolean Union: implausible result volume ","faces":29,"outcome":"failed","rejected":0,"shells":3,"steps":["revolved section","join cylinder","fillet","shell","cut box","join box"],"unsupported":1,"volume":14286.791174}, @@ -52,7 +52,7 @@ "48": {"area":9651.031297,"bodies":1,"edges":66,"error":null,"faces":28,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","hole","circular pattern","join box","shell","cut box"],"unsupported":2,"volume":7044.773463}, "49": {"area":7512.5,"bodies":1,"edges":24,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["box","join box"],"unsupported":0,"volume":40546.875}, "50": {"area":9387.513708,"bodies":2,"edges":48,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":2,"steps":["extruded profile","fillet","join cylinder","join cylinder","fillet","cut box","cut cylinder"],"unsupported":1,"volume":40128.140215}, - "51": {"area":3316.022299,"bodies":1,"edges":51,"error":null,"faces":21,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut box","hole","rectangular pattern","chamfer","chamfer"],"unsupported":1,"volume":6812.582331}, + "51": {"area":4230.232922,"bodies":1,"edges":66,"error":null,"faces":26,"outcome":"ok","rejected":1,"shells":2,"steps":["box","cut box","hole","rectangular pattern","chamfer","join box"],"unsupported":1,"volume":8742.066663}, "52": {"area":7851.282527,"bodies":1,"edges":32,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["box","shell","cut cylinder","chamfer","fillet"],"unsupported":0,"volume":5741.788461}, "53": {"area":17168.464938,"bodies":1,"edges":110,"error":null,"faces":46,"outcome":"ok","rejected":1,"shells":4,"steps":["extruded profile","cut cylinder","shell","join cylinder","circular pattern","fillet"],"unsupported":0,"volume":16607.030819}, "54": {"area":7080.555724,"bodies":4,"edges":90,"error":null,"faces":38,"outcome":"ok","rejected":0,"shells":4,"steps":["extruded profile","join box","circular pattern","cut cylinder","cut box","join cylinder","cut cylinder"],"unsupported":3,"volume":15737.224446}, @@ -110,6 +110,7 @@ {"reason":"#921 with its grid lines kept to display and export meshes (Booleans mesh as before #921): measured volume and area move by about 1e-6 (seed 94 also as in #936)","seeds":[22,78,94]}, {"reason":"a hole placed on a face without a direction drills into that face (it took the direction from any face whose plane passed through the point, the last one found, such as a side of a patterned box standing on the point's plane): the holes on these seeds now go straight in (#950)","seeds":[7,21,85]}, {"reason":"the last-resort tool nudge is tried the other way when it fails or leaves sliver faces the operands didn't have: seed 43's join builds and the design runs to the end, 31 and 78 come out with fewer faces (#827, #897)","seeds":[31,43,78]}, - {"reason":"main's fillet changes since the last record (#926, #949) move seeds 33 and 91: a fillet step builds differently or is picked differently","seeds":[33,91]} + {"reason":"main's fillet changes since the last record (#926, #949) move seeds 33 and 91: a fillet step builds differently or is picked differently","seeds":[33,91]}, + {"reason":"hole tangent inside a stepped boss: touching sews split zero-thickness curved walls, so a pattern of holes spaced by their diameter (touching) in a stepped body cuts exactly instead of by a tool nudge: 42 builds more faces (the walls split where the holes touch), 51 picks other edges afterwards","seeds":[42,51]} ] }