diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index 39869d4d3d..e3cbc5e2eb 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -146,12 +146,15 @@ fn non_antipodal_seam_sphere_imports_as_a_whole_ball() { /// A whole ring torus (major radius 10, minor 3, about Z) written as one face bounded only by /// seams: its tube circle at u = 0 and its ring circle at v = 0, each run out and back, in -/// either order round the loop. Each is the whole torus, volume 2π²Rr² and area 4π²Rr. +/// either order round the loop; or only a slit along the tube circle, from the outer equator 2.5 +/// rad towards the top and back, the torus less a slit of no area. Each is the whole torus, +/// volume 2π²Rr² and area 4π²Rr. #[test] fn seam_only_torus_imports_as_a_whole_ring() { use std::f64::consts::PI; let (major, minor) = (10.0_f64, 3.0_f64); let outer = major + minor; + let (end_x, end_z) = (major + minor * 2.5_f64.cos(), minor * 2.5_f64.sin()); let tube = format!( "#14=CARTESIAN_POINT('',({major:.16},0.,0.));\n#15=DIRECTION('',(0.,-1.,0.));\n#16=AXIS2_PLACEMENT_3D('',#14,#15,#3);\n#17=CIRCLE('',#16,{minor:.16});\n" ); @@ -159,11 +162,19 @@ fn seam_only_torus_imports_as_a_whole_ring() { let cases = [ ("tube then ring", format!("{tube}#20=EDGE_CURVE('',#11,#11,#17,.T.);\n{ring}#30=EDGE_LOOP('',(#21,#22,#23,#24));\n")), ("ring then tube", format!("{tube}#20=EDGE_CURVE('',#11,#11,#17,.T.);\n{ring}#30=EDGE_LOOP('',(#22,#21,#24,#23));\n")), + ( + "a slit along the tube", + format!( + "#12=CARTESIAN_POINT('',({end_x:.16},0.,{end_z:.16}));\n#13=VERTEX_POINT('',#12);\n{tube}#20=EDGE_CURVE('',#11,#13,#17,.T.);\n#30=EDGE_LOOP('',(#21,#23));\n" + ), + ), ]; for (what, edges) in cases { let mut t = String::from(HEAD); t += "#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=DIRECTION('',(0.,0.,1.));\n#3=DIRECTION('',(1.,0.,0.));\n#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n"; - t += &format!("#5=TOROIDAL_SURFACE('',#4,{major:.16},{minor:.16});\n#10=CARTESIAN_POINT('',({outer:.16},0.,0.));\n#11=VERTEX_POINT('',#10);\n"); + t += &format!( + "#5=TOROIDAL_SURFACE('',#4,{major:.16},{minor:.16});\n#10=CARTESIAN_POINT('',({outer:.16},0.,0.));\n#11=VERTEX_POINT('',#10);\n" + ); t += &edges; t += "#21=ORIENTED_EDGE('',*,*,#20,.T.);\n#22=ORIENTED_EDGE('',*,*,#19,.T.);\n#23=ORIENTED_EDGE('',*,*,#20,.F.);\n#24=ORIENTED_EDGE('',*,*,#19,.F.);\n"; t += "#31=FACE_OUTER_BOUND('',#30,.T.);\n#32=ADVANCED_FACE('',(#31),#5,.T.);\n#33=CLOSED_SHELL('',(#32));\n#34=MANIFOLD_SOLID_BREP('Ring',#33);\n"; diff --git a/crates/step/src/topology.rs b/crates/step/src/topology.rs index b97083581d..2c7bba5f42 100644 --- a/crates/step/src/topology.rs +++ b/crates/step/src/topology.rs @@ -943,9 +943,10 @@ impl<'b, 'a> Builder<'b, 'a> { fn finish(mut self, item: u64, name: String, shells: Vec>, kind: Kind) -> R { let _span = solidcraft_profile::span("import", "step.solid_record"); let mut notes = Vec::new(); - // A whole sphere bounded by one seam has its frame on that seam, before anything is - // measured on the frame. - self.seat_spheres_on_seams()?; + // A face bounded by a slit alone is the whole of its closed surface, closed by its seams + // (a sphere's frame put on the slit), before anything is measured on the frame or split + // at its poles. + self.complete_slits()?; let at_poles = self.split_at_poles()?; if at_poles > 0 { notes.push(format!("{at_poles} edges split where they pass through a face's pole")); diff --git a/crates/step/src/topology/frames.rs b/crates/step/src/topology/frames.rs index 7759cc3ace..c6266a7f43 100644 --- a/crates/step/src/topology/frames.rs +++ b/crates/step/src/topology/frames.rs @@ -28,8 +28,7 @@ use std::f64::consts::TAU; use solidcraft_geom::Vec3; -use solidcraft_geom::brep::eval::CurveEval; -use solidcraft_geom::brep::{self, Curve as BCurve, Frame, Surface as BSurface}; +use solidcraft_geom::brep::{self, Curve as BCurve}; use super::{Builder, LoopB, R}; @@ -257,50 +256,3 @@ impl Builder<'_, '_> { Ok(turned) } } - -impl Builder<'_, '_> { - /// Put the frame of each whole sphere bounded by one seam alone (a meridian from pole to - /// pole, used both ways) on that seam: its axis through the seam's ends, its x axis through - /// the seam's middle. A sphere's frame is the writer's choice (any right-handed frame about - /// the centre is the same surface, its natural normal outward); one whose poles are not the - /// seam's leaves the seam a slit across the form, not the form's own seam, and no kernel - /// takes that as the whole ball. Returns how many frames were moved. - pub(super) fn seat_spheres_on_seams(&mut self) -> R { - let mut seated = 0; - for fi in 0..self.faces.len() { - let Some(face) = self.faces.get(fi) else { continue }; - let BSurface::Sphere { frame, radius } = &face.surface else { continue }; - let (frame, radius) = (frame.clone(), *radius); - let [LoopB::Edges(uses)] = face.loops.as_slice() else { continue }; - let [(e, a), (f, b)] = uses.as_slice() else { continue }; - if e != f || a == b { - continue; - } - let edge = self.edges.get(*e as usize).ok_or("edge reference")?; - let point = |v: u32| self.vertices.get(v as usize).map(|x| (Vec3::from(x.point), x.tolerance)); - let (Some((start, t0)), Some((end, t1))) = (point(edge.vertices[0]), point(edge.vertices[1])) else { continue }; - let centre = Vec3::from(frame.origin); - let reach = self.tol.max(edge.tolerance).max(t0).max(t1); - // The seam runs between antipodes: its ends on the sphere, opposite each other. - if (start.dist(centre) - radius.abs()).abs() > reach || (start - centre).dist(centre - end) > 2.0 * reach { - continue; - } - let Some(z) = (start - end).normalized() else { continue }; - let axis = Vec3::from(frame.x_axis).cross(Vec3::from(frame.y_axis)); - // Already the frame's poles. - if axis.cross(z).len() * radius.abs() <= reach { - continue; - } - let c = CurveEval::new(&edge.curve).map_err(|m| m.to_string())?; - let middle = c.point(0.5 * (edge.interval[0] + edge.interval[1])).ok_or("edge point")?; - let off = middle - centre; - let Some(x) = (off - z * off.dot(z)).normalized() else { continue }; - let y = z.cross(x); - let Some(face) = self.faces.get_mut(fi) else { continue }; - face.surface = BSurface::Sphere { frame: Frame { origin: frame.origin, x_axis: [x.x, x.y, x.z], y_axis: [y.x, y.y, y.z] }, radius }; - face.extent = None; - seated += 1; - } - Ok(seated) - } -} diff --git a/crates/step/src/topology/seams.rs b/crates/step/src/topology/seams.rs index 3b9f3b488e..125f42aadd 100644 --- a/crates/step/src/topology/seams.rs +++ b/crates/step/src/topology/seams.rs @@ -10,6 +10,10 @@ //! column (u at the start of its knots), the loops split there where they have no vertex on it; //! the period is the width of its knots. A torus face winding without a seam, and a spline //! closed in v or both ways, are refused for now. +//! +//! A face bounded by a slit alone (a loop out along edges and straight back, of no (u, v) area) +//! on a closed surface is all of that surface less a cut of none: its slit is made its seams +//! ([`Builder::complete_slits`]), so the loop bounds the surface's whole (u, v) domain. use std::f64::consts::{PI, TAU}; @@ -37,6 +41,21 @@ fn wrap_by(x: f64, period: f64) -> f64 { x - (x / period).round() * period } +/// The path a slit runs out along: a loop that runs out along a chain of edges and straight back +/// along it, each edge used once each way, started at one of its two turns; `None` for any +/// other loop. +pub(super) fn slit_path(es: &[(u32, bool)]) -> Option> { + let n = es.len(); + if n < 2 || !n.is_multiple_of(2) || !super::seam_only(es) { + return None; + } + // A turn: a use followed by its edge the other way. + let k = (0..n).find(|&k| matches!((es.get((k + n - 1) % n), es.get(k)), (Some(a), Some(b)) if a.0 == b.0))?; + let ring: Vec<(u32, bool)> = es.iter().skip(k).chain(es.iter().take(k)).copied().collect(); + let (out, back) = ring.split_at(n / 2); + back.iter().rev().zip(out).all(|(b, o)| b.0 == o.0 && b.1 != o.1).then(|| out.to_vec()) +} + /// The axis frame of a surface of revolution: origin, unit axis, and the pole points at the /// ends of its v range that have one (min v, max v). struct Axis { @@ -457,14 +476,23 @@ impl Builder<'_, '_> { let (curve, interval, along) = match &ax.kind { Kind::Ruled => (BCurve::Line { start: arr(pa), end: arr(pb) }, [0.0, 1.0], true), Kind::Sphere(r) => { - let x = (pa - ax.origin).normalized().ok_or("a seam from the sphere's centre")?; - let to = pb - ax.origin; - let y = (to - x * to.dot(x)).normalized().ok_or("a seam between opposite points")?; - let theta = to.dot(y).atan2(to.dot(x)); - if !(theta > 0.0 && theta <= PI) { - return Err("a degenerate seam".into()); + // The sphere's own meridian through the end further from the axis: angle 0 on the + // equator, π/2 at the upper pole, so its angle is the surface's latitude and its + // quarters the surface's. + let (da, db) = (pa - ax.origin, pb - ax.origin); + let (ra, rb) = (da - ax.z * da.dot(ax.z), db - ax.z * db.dot(ax.z)); + let x = if ra.len() >= rb.len() { ra } else { rb }.normalized().ok_or("a seam along the sphere's axis")?; + let latitude = |d: Vec3| d.dot(ax.z).atan2(d.dot(x)); + let (ta, tb) = (latitude(da), latitude(db)); + if !(ta.abs() <= 0.5 * PI + 1e-9 && tb.abs() <= 0.5 * PI + 1e-9) { + return Err(unsupported("a seam between loops at opposite angles")); } - (BCurve::Circle { frame: Frame { origin: arr(ax.origin), x_axis: arr(x), y_axis: arr(y) }, radius: *r }, [0.0, theta], true) + let along = ta < tb; + ( + BCurve::Circle { frame: Frame { origin: arr(ax.origin), x_axis: arr(x), y_axis: arr(ax.z) }, radius: *r }, + if along { [ta, tb] } else { [tb, ta] }, + along, + ) } Kind::Iso { column, .. } => { // Each vertex's v on the surface (a pole's from its end of the v range). @@ -507,7 +535,14 @@ impl Builder<'_, '_> { return Err(unsupported("a seam off its surface: loops with no vertices at one angle")); } } - let vertices = if along { [a, b] } else { [b, a] }; + let i = self.new_edge(curve, interval, if along { [a, b] } else { [b, a] })?; + Ok((i, along)) + } + + /// A new edge on `curve` over `interval` between `vertices`, each vertex's tolerance raised to + /// cover its gap to the curve's end. + fn new_edge(&mut self, curve: BCurve, interval: [f64; 2], vertices: [u32; 2]) -> R { + let c = CurveEval::new(&curve).map_err(|m| m.to_string())?; for (t, v) in interval.into_iter().zip(vertices) { let gap = c.point(t).ok_or("seam point")?.dist(self.vpoint(v)?); if let Some(x) = self.vertices.get_mut(v as usize) { @@ -516,7 +551,242 @@ impl Builder<'_, '_> { } let i = index(self.edges.len())?; self.edges.push(EdgeB { curve, interval, vertices, tolerance: self.tol, form: None }); - Ok((i, along)) + Ok(i) + } + + /// Complete every face bounded by a slit alone into the whole of its surface. A slit is a + /// loop that runs out along a chain of edges and straight back ([`slit_path`]): it encloses + /// no (u, v) area, yet on a closed surface it bounds all of it less a cut of none (a ball + /// whose one face is bounded by part of a meridian, out and back). Schema 4 keeps that face + /// as the surface's whole (u, v) domain, closed by its seams, so the slit is made one: on a + /// surface of revolution closed at both poles (a sphere, a revolution whose profile ends on + /// its axis, a spline closed round u that collapses at both v ends), the meridian the slit + /// lies along is run on from its ends to the poles, out and back; on a torus, the circle the + /// slit lies along (round the tube or round the axis) is closed and the other circle through + /// its start is the second seam. Returns how many faces were completed. + pub(super) fn complete_slits(&mut self) -> R { + let mut completed = 0; + for fi in 0..self.faces.len() { + let Some(face) = self.faces.get(fi) else { continue }; + let [LoopB::Edges(es)] = face.loops.as_slice() else { continue }; + let Some(path) = slit_path(es) else { continue }; + let (step, surface) = (face.step, face.surface.clone()); + let (merged, seated) = match &surface { + BSurface::Torus { frame, major_radius, .. } => (self.torus_slit(&path, frame, *major_radius)?, None), + _ => match self.closed_at_poles(&path, &surface)? { + // An open surface: the slit bounds nothing, and is refused for that later. + None => continue, + Some(Some(m)) => (Some(m), None), + // A sphere's frame is the writer's choice (any right-handed frame about its + // centre is the same surface, its natural normal outward): one whose + // meridians miss the slit has its pole put at the slit's start instead, its x + // axis through the slit's middle. + Some(None) => match &surface { + BSurface::Sphere { frame, radius } => match self.seated(&path, frame, *radius)? { + Some(s) => (self.closed_at_poles(&path, &s)?.flatten(), Some(s)), + None => (None, None), + }, + _ => (None, None), + }, + }, + }; + let merged = merged.ok_or_else(|| format!("face #{step}: {}", unsupported("a face bounded by a slit off its surface's seam lines")))?; + let Some(face) = self.faces.get_mut(fi) else { continue }; + if let Some(s) = seated { + face.surface = s; + face.extent = None; + } + if let Some(LoopB::Edges(es)) = face.loops.first_mut() { + *es = merged; + } + completed += 1; + } + Ok(completed) + } + + /// The slit made the meridian seam of `surface` when it is a surface of revolution closed + /// at both poles ([`Builder::meridian_slit`]); `None` for any other surface. + fn closed_at_poles(&mut self, path: &[(u32, bool)], surface: &BSurface) -> R>>> { + let eval = SurfaceEval::new(surface).map_err(|e| e.to_string())?; + match axis(surface).or_else(|| spline_axis(surface, &eval)) { + Some(ax) if ax.poles.iter().all(Option::is_some) => Ok(Some(self.meridian_slit(path, &ax, &eval)?)), + _ => Ok(None), + } + } + + /// The sphere about `frame`'s centre with its upper pole at the slit's start and its x axis + /// through the middle of the slit's middle edge; `None` where those coincide. + fn seated(&self, path: &[(u32, bool)], frame: &Frame, radius: f64) -> R> { + let centre = Vec3::from(frame.origin); + let first = *path.first().ok_or("empty slit")?; + let Some(z) = (self.vpoint(self.start(first)?)? - centre).normalized() else { return Ok(None) }; + let &(e, _) = path.get(path.len() / 2).ok_or("empty slit")?; + let edge = self.edges.get(e as usize).ok_or("edge reference")?; + let middle = + CurveEval::new(&edge.curve).map_err(|m| m.to_string())?.point(0.5 * (edge.interval[0] + edge.interval[1])).ok_or("edge point")?; + let off = middle - centre; + let Some(x) = (off - z * off.dot(z)).normalized() else { return Ok(None) }; + Ok(Some(BSurface::Sphere { frame: Frame { origin: frame.origin, x_axis: arr(x), y_axis: arr(z.cross(x)) }, radius })) + } + + /// The vertex use `u` ends at. + fn end(&self, (e, forward): (u32, bool)) -> R { + self.start((e, !forward)) + } + + /// Vertex `v` is at `p`, within its tolerance. + fn at(&self, v: u32, p: Vec3) -> R { + let x = self.vertices.get(v as usize).ok_or("vertex reference")?; + Ok(Vec3::from(x.point).dist(p) <= self.tol.max(x.tolerance)) + } + + /// Every point of the path's edges lies on one meridian of the surface (the spline's + /// boundary column for [`Kind::Iso`]), the poles apart. + fn on_meridian(&self, path: &[(u32, bool)], ax: &Axis, eval: &SurfaceEval) -> R { + let mut u_ref = match ax.kind { + Kind::Iso { u0, .. } => Some(u0), + _ => None, + }; + for &(e, _) in path { + let edge = self.edges.get(e as usize).ok_or("edge reference")?; + let c = CurveEval::new(&edge.curve).map_err(|m| m.to_string())?; + let ([lo, hi], reach) = (edge.interval, self.tol.max(edge.tolerance)); + for k in 0..=SAMPLES { + let p = c.point(lo + (hi - lo) * k as f64 / SAMPLES as f64).ok_or("edge point")?; + if ax.poles.iter().flatten().any(|q| q.dist(p) <= reach) { + continue; + } + let Some(q) = eval.project(p, None, f64::INFINITY) else { return Ok(false) }; + let u = *u_ref.get_or_insert(q[0]); + if !eval.point([u, q[1]]).is_some_and(|m| m.dist(p) <= reach) { + return Ok(false); + } + } + } + Ok(u_ref.is_some()) + } + + /// A slit along a meridian of a surface closed at both poles, run on to them: the meridian + /// from the lower pole to the upper (the slit's path, with a seam edge from each end of it + /// that is not at its pole), then back. `None` when the slit is off every meridian. + fn meridian_slit(&mut self, path: &[(u32, bool)], ax: &Axis, eval: &SurfaceEval) -> R>> { + let (Some(south), Some(north)) = (ax.poles[0], ax.poles[1]) else { return Ok(None) }; + if !self.on_meridian(path, ax, eval)? { + return Ok(None); + } + // How far up the meridian a vertex is: a pole's from its end, else its v. + let height = |s: &Self, v: u32| -> R { + Ok(if s.at(v, south)? { + f64::NEG_INFINITY + } else if s.at(v, north)? { + f64::INFINITY + } else { + eval.project(s.vpoint(v)?, None, f64::INFINITY).ok_or("a slit vertex off its surface")?[1] + }) + }; + let (first, last) = (*path.first().ok_or("empty slit")?, *path.last().ok_or("empty slit")?); + let (ha, hb) = (height(self, self.start(first)?)?, height(self, self.end(last)?)?); + if !(ha != hb) { + return Ok(None); + } + // Up the meridian. + let mut up = path.to_vec(); + if ha > hb { + super::reverse(&mut up); + } + let (a, b) = (self.start(*up.first().ok_or("empty slit")?)?, self.end(*up.last().ok_or("empty slit")?)?); + let pole_vertex = |s: &mut Self, p: Vec3| -> R { + let v = index(s.vertices.len())?; + s.vertices.push(Vertex { point: arr(p), tolerance: s.tol }); + Ok(v) + }; + let mut merged = Vec::with_capacity(2 * up.len() + 4); + if !self.at(a, south)? { + let s = pole_vertex(self, south)?; + let (seam, along) = self.seam_edge(a, s, ax, eval, true)?; + merged.push((seam, !along)); + } + merged.extend(up); + if !self.at(b, north)? { + let n = pole_vertex(self, north)?; + merged.push(self.seam_edge(b, n, ax, eval, true)?); + } + let mut back = merged.clone(); + super::reverse(&mut back); + merged.extend(back); + Ok(Some(merged)) + } + + /// A slit along a circle of a torus (round its tube, or round its axis) made the torus's two + /// seams: that circle closed from the slit's end round to its start, the way the slit runs, + /// and the other circle through the start; the loop runs round the first, the second, the + /// first back and the second back. `None` when the slit is off both circles. + fn torus_slit(&mut self, path: &[(u32, bool)], frame: &Frame, major: f64) -> R>> { + let (o, z) = (Vec3::from(frame.origin), Vec3::from(frame.x_axis).cross(Vec3::from(frame.y_axis))); + let (first, last) = (*path.first().ok_or("empty slit")?, *path.last().ok_or("empty slit")?); + let (a, b) = (self.start(first)?, self.end(last)?); + let (pa, pb) = (self.vpoint(a)?, self.vpoint(b)?); + let off = pa - o; + let h = off.dot(z); + let Some(radial) = (off - z * h).normalized() else { return Ok(None) }; + // The circles through the slit's start, as centre and unit normal: round the tube in the + // plane through the axis, and round the axis at the start's height. + let tube = (o + radial * major, radial.cross(z)); + let ring = (o + z * h, z); + let along = |s: &Self, (c, n): (Vec3, Vec3)| -> R { + let r = (pa - c).len(); + for &(e, _) in path { + let edge = s.edges.get(e as usize).ok_or("edge reference")?; + let curve = CurveEval::new(&edge.curve).map_err(|m| m.to_string())?; + let ([lo, hi], reach) = (edge.interval, s.tol.max(edge.tolerance)); + for k in 0..=SAMPLES { + let d = curve.point(lo + (hi - lo) * k as f64 / SAMPLES as f64).ok_or("edge point")? - c; + if !(d.dot(n).abs() <= reach && (d.len() - r).abs() <= reach) { + return Ok(false); + } + } + } + Ok(true) + }; + let (circle, other) = if along(self, tube)? { + (tube, ring) + } else if along(self, ring)? { + (ring, tube) + } else { + return Ok(None); + }; + let mut round = path.to_vec(); + if a != b { + // On from the slit's end the way it arrives there, from a point of its last edge + // just before. + let (c, n) = circle; + let edge = self.edges.get(last.0 as usize).ok_or("edge reference")?; + let [lo, hi] = edge.interval; + let t = if last.1 { hi - (hi - lo) / SAMPLES as f64 } else { lo + (hi - lo) / SAMPLES as f64 }; + let before = CurveEval::new(&edge.curve).map_err(|m| m.to_string())?.point(t).ok_or("edge point")?; + let Some(x) = (pb - c).normalized() else { return Ok(None) }; + let y = n.cross(x); + let y = if (pb - before).dot(y) >= 0.0 { y } else { -y }; + let d = pa - c; + let theta = d.dot(y).atan2(d.dot(x)).rem_euclid(TAU); + let radius = (pb - c).len(); + if !(theta * radius > self.tol) { + return Ok(None); + } + let rest = BCurve::Circle { frame: Frame { origin: arr(c), x_axis: arr(x), y_axis: arr(y) }, radius }; + round.push((self.new_edge(rest, [0.0, theta], [b, a])?, true)); + } + let (c, n) = other; + let Some(x) = (pa - c).normalized() else { return Ok(None) }; + let full = BCurve::Circle { frame: Frame { origin: arr(c), x_axis: arr(x), y_axis: arr(n.cross(x)) }, radius: (pa - c).len() }; + let second = self.new_edge(full, [0.0, TAU], [a, a])?; + let mut back = round.clone(); + super::reverse(&mut back); + let mut merged = round; + merged.push((second, true)); + merged.extend(back); + merged.push((second, false)); + Ok(Some(merged)) } /// Join loop `li` of face `fi` to the pole the face covers: seam out and back. diff --git a/tests/KNOWN_FAILING_TESTS b/tests/KNOWN_FAILING_TESTS index 22660b1d84..9a5c194fc7 100644 --- a/tests/KNOWN_FAILING_TESTS +++ b/tests/KNOWN_FAILING_TESTS @@ -16,5 +16,4 @@ solidcraft-kernel::blend::corner_tests::corner_blends_hold_at_any_scale at 0.01x solidcraft-kernel::blend_corner::tests::issue2422_vertical_after_separate_bottom_rounds SMLib's check refuses the fillet: edge and vertex tolerance 0.97 against the body's 0.01 (#2422) solidcraft-kernel::corner_g2::tests::a_long_setback_keeps_the_g2_patch_inside_the_box at set backs 11 to 15 the G2 corner patch bulges 2.6e-6 to 1.33e-3 past the box (the explicit corner is in progress) solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, which SMLib's exact tight box now sees (the explicit G2 corner builds it inside) -solidcraft-kernel::step_in::tests::non_antipodal_seam_sphere_imports_as_a_whole_ball the import refuses the ball: its face's loop, a meridian slit from the pole run out and back, encloses no (u, v) area, so the whole sphere less a slit is not read as the sphere solidcraft-kernel::tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole the arc and tab rounds together remove 9.20067 against 9.20420 (3.8e-4 off, 1e-5 allowed)