From 1c7389eff14a8e1f99b21ba4842368f7a5e904fd Mon Sep 17 00:00:00 2001 From: Bas <16050771+Basnederveen@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:23:02 +0100 Subject: [PATCH] fix(booleans): closed-loop booleans on spheres, cones and cylinders Four bugs in the closed-loop Booleans; several returned solids that pass validate() but are wrong. - A loop winding around a sphere's polar axis was classified by its UV centroid in the sphere's own chart, where it has no inside, while the side was built in a reframed chart. The contractible chart (assembly/sphere_loop_chart.rs, now centred on longitude pi) is computed first, the Patch is classified in it, and a Complement is a full sphere rebuilt in that chart with the loop as a hole. - The offset sphere-cylinder handler had the same problem; a loop that winds the sphere is now a coverage gap there, so dispatch falls through to the generic closed-loop Boolean. - A hole's pcurve could lie a whole period outside its face's UV window (cone with a sphere on +y), so the face kept its material. Holes are lifted into the window with periodic_lift. - A hole that fits no lift crosses the face's chart seam; that is now a CoverageGap ("closed intersection loop as a hole") instead of a wrong solid (cylinder r 2 with a crossing sphere: intersection 0.986 against a sphere of 0.522). Golden: the poking-ring scenes (loops across both tori's seams, results that misclassify points inside the host tube) are now refused, so a poking ring clear of the seams is added to keep generic_multiple_closed_loops_boolean covered. The aarch64-apple-darwin golden file is not re-recorded here: 9 scene texts change and 3 are new. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VFZvqx1RmXzPtbL9VXP4H7 --- .../modifying/boolean/assembly/closed_loop.rs | 194 ++++--------- .../boolean/assembly/loop_pcurves.rs | 12 +- .../modifying/boolean/assembly/mod.rs | 10 +- .../boolean/assembly/sphere_loop_chart.rs | 191 +++++++++++++ .../handlers/generic/single_closed_loop.rs | 125 ++++++-- .../handlers/sphere_cylinder/offset.rs | 25 +- .../boolean/tests/closed_loop_windings.rs | 270 ++++++++++++++++++ .../modifying/boolean/tests/generic.rs | 50 ++-- .../operations/modifying/boolean/tests/mod.rs | 1 + .../boolean/tests/sphere_cylinder.rs | 87 +++--- .../modifying/boolean/tests/support.rs | 30 +- .../tests/golden/booleans_spheres.rs | 10 + 12 files changed, 743 insertions(+), 262 deletions(-) create mode 100644 main/opengeometry/src/operations/modifying/boolean/assembly/sphere_loop_chart.rs create mode 100644 main/opengeometry/src/operations/modifying/boolean/tests/closed_loop_windings.rs diff --git a/main/opengeometry/src/operations/modifying/boolean/assembly/closed_loop.rs b/main/opengeometry/src/operations/modifying/boolean/assembly/closed_loop.rs index 8322445c..4c11ce87 100644 --- a/main/opengeometry/src/operations/modifying/boolean/assembly/closed_loop.rs +++ b/main/opengeometry/src/operations/modifying/boolean/assembly/closed_loop.rs @@ -4,13 +4,14 @@ use super::loop_pcurves::{ topology_loop_bounds, }; use super::provenance::reverse; +use super::sphere_loop_chart::SphereLoopChart; use crate::brep::{ - unit, BrepEnvelope, CurveGeometry, Edge, EdgeGeometry, Face, FaceProvenance, FaceRole, Frame3, - GeometryError, GeometryStore, HalfEdge, HalfEdgeGeometryUse, IntersectionDefinition, Loop, - Orientation, PcurveGeometry, Shell, SolidRegion, Surface, SurfaceGeometry, TrimRegion, Vertex, + BrepEnvelope, CurveGeometry, Edge, EdgeGeometry, Face, FaceProvenance, FaceRole, GeometryError, + GeometryStore, HalfEdge, HalfEdgeGeometryUse, IntersectionDefinition, Loop, Orientation, + PcurveGeometry, Shell, SolidRegion, Surface, TrimRegion, Vertex, }; use crate::intersection::{IntersectionBranch, IntersectionGraph}; -use crate::math::{dot, Interval, Point3}; +use crate::math::Interval; use crate::operations::modifying::boolean::operands::brep_face_source; use crate::operations::modifying::boolean::types::BooleanOp; use crate::query::{classify_point, PointClassification}; @@ -35,12 +36,6 @@ pub(crate) struct ClosedBranchEdge { pub(crate) pcurves: [PcurveGeometry; 2], } -struct SphereCandidate { - score: f64, - surface: SurfaceGeometry, - coordinates: Vec<[f64; 2]>, -} - pub(crate) struct PatchLoop { pub(crate) face: u32, loop_id: u32, @@ -52,18 +47,19 @@ pub(crate) fn loop_containment( b: &BrepEnvelope, graph: &IntersectionGraph, branch: &IntersectionBranch, + charts: &[Option; 2], unclassified: &str, ) -> Result<[bool; 2], GeometryError> { - let loop_inside_other = [ - classify_point( - b, - graph.geometry.surfaces[0].point_at(intersection_loop_sample(graph, branch, 0)?)?, - )?, - classify_point( - a, - graph.geometry.surfaces[1].point_at(intersection_loop_sample(graph, branch, 1)?)?, - )?, - ]; + let inputs = [a, b]; + let patch_inside_other = |side: usize| { + let sample = match &charts[side] { + Some(chart) => chart.patch_sample()?, + None => graph.geometry.surfaces[side] + .point_at(intersection_loop_sample(graph, branch, side)?)?, + }; + classify_point(inputs[1 - side], sample) + }; + let loop_inside_other = [patch_inside_other(0)?, patch_inside_other(1)?]; if loop_inside_other.iter().any(|classification| { matches!( classification, @@ -300,124 +296,6 @@ pub(crate) fn intersection_definition( .clone()) } -pub(crate) fn reframe_sphere_intersection( - geometry: &mut GeometryStore, - definition: &mut IntersectionDefinition, - side: usize, -) -> Result<(), GeometryError> { - let surface_id = definition.surfaces[side]; - let (frame, radius) = match geometry.surface(surface_id)? { - SurfaceGeometry::Sphere { frame, radius } => (*frame, *radius), - _ => { - return Err(GeometryError::CoverageGap { - families: ["periodic winding".into(), "non-spherical support".into()], - }) - } - }; - let references = [frame.x, frame.y, frame.z]; - let mut best: Option = None; - for i in -1..=1 { - for j in -1..=1 { - for k in -1..=1 { - if [i, j, k] == [0, 0, 0] { - continue; - } - let axis = unit(frame.vector([i as f64, j as f64, k as f64]))?; - let Some(candidate) = - scored_sphere_candidate(frame, radius, references, axis, definition)? - else { - continue; - }; - if best - .as_ref() - .is_none_or(|best| candidate.score < best.score) - { - best = Some(candidate); - } - } - } - } - let SphereCandidate { - surface: candidate, - coordinates, - .. - } = best.ok_or_else(|| GeometryError::CoverageGap { - families: ["sphere".into(), "unresolved periodic winding".into()], - })?; - geometry.surfaces[surface_id as usize] = candidate; - for (anchor, uv) in definition.anchors.iter_mut().zip(&coordinates) { - if side == 0 { - anchor.uv_a = *uv; - } else { - anchor.uv_b = *uv; - } - } - let offset = side * 2; - for (index, tube) in definition.uv_tubes.iter_mut().enumerate() { - for axis in 0..2 { - let a = coordinates[index][axis]; - let b = coordinates[index + 1][axis]; - let padding = (a - b).abs().max(definition.residual_tolerance / radius) * 2.0; - tube[offset + axis] = Interval::new(a.min(b) - padding, a.max(b) + padding)?; - } - } - definition.validate(geometry) -} - -fn scored_sphere_candidate( - frame: Frame3, - radius: f64, - references: [Point3; 3], - axis: Point3, - definition: &IntersectionDefinition, -) -> Result, GeometryError> { - let reference = references - .into_iter() - .min_by(|left, right| dot(*left, axis).abs().total_cmp(&dot(*right, axis).abs())) - .ok_or_else(|| GeometryError::InvalidGeometry("no reference axes".into()))?; - let candidate = SurfaceGeometry::Sphere { - frame: Frame3::from_axis(frame.origin, axis, reference)?, - radius, - }; - let mut coordinates = Vec::with_capacity(definition.anchors.len()); - let mut hint = None; - let mut failed = false; - for anchor in &definition.anchors { - match candidate.project(anchor.point, hint) { - Ok(uv) => { - coordinates.push(uv); - hint = Some(uv); - } - Err(_) => { - failed = true; - break; - } - } - } - if failed { - return Ok(None); - } - let Some(last) = coordinates.last() else { - return Ok(None); - }; - let longitude_span = (last[0] - coordinates[0][0]).abs(); - let pole_proximity = coordinates - .iter() - .map(|uv| uv[1].abs()) - .fold(0.0_f64, f64::max); - if longitude_span >= std::f64::consts::PI - || pole_proximity >= std::f64::consts::FRAC_PI_2 - 1.0e-6 - { - return Ok(None); - } - let score = pole_proximity + longitude_span; - Ok(Some(SphereCandidate { - score, - surface: candidate, - coordinates, - })) -} - pub(crate) fn loop_sense(side: usize, reversed: bool) -> Orientation { let nominal_sense = if side == 0 { Orientation::Forward @@ -448,7 +326,45 @@ pub(crate) fn add_hole_loop( sense, true, )?; - let loop_bounds = topology_loop_bounds(out, loop_id, tolerance)?; + add_hole_lifted_into_face(out, face, loop_id, tolerance) +} + +pub(crate) fn add_hole_lifted_into_face( + out: &mut BrepEnvelope, + face: u32, + loop_id: u32, + tolerance: f64, +) -> Result<(), GeometryError> { + let face_record = &out.topology.faces[face as usize]; + let window = face_record.trim.uv_bounds; + let periods = out.geometry.surface(face_record.surface)?.charts() + [face_record.trim.chart as usize] + .periods; + let mut loop_bounds = topology_loop_bounds(out, loop_id, tolerance)?; + let mut lift = [0; 2]; + for axis in 0..2 { + let Some(period) = periods[axis] else { + continue; + }; + let turns = ((window[axis].lo - loop_bounds[axis].lo) / period).ceil(); + if loop_bounds[axis].hi + turns * period > window[axis].hi { + return Err(GeometryError::CoverageGap { + families: [ + "closed intersection loop as a hole".into(), + format!("loop crossing the chart seam of face '{}'", face_record.key), + ], + }); + } + loop_bounds[axis] = Interval::new( + loop_bounds[axis].lo + turns * period, + loop_bounds[axis].hi + turns * period, + )?; + lift[axis] = turns as i32; + } + let start = out.topology.loops[loop_id as usize].start_halfedge; + out.topology.halfedges[start as usize] + .geometry_use + .periodic_lift = lift; widen_trim_bounds(out, face, loop_bounds)?; out.topology.faces[face as usize].trim.holes.push(loop_id); Ok(()) diff --git a/main/opengeometry/src/operations/modifying/boolean/assembly/loop_pcurves.rs b/main/opengeometry/src/operations/modifying/boolean/assembly/loop_pcurves.rs index ef1d854b..db8af72a 100644 --- a/main/opengeometry/src/operations/modifying/boolean/assembly/loop_pcurves.rs +++ b/main/opengeometry/src/operations/modifying/boolean/assembly/loop_pcurves.rs @@ -146,6 +146,10 @@ pub(crate) fn intersection_loop_sample( let parameter = branch.range.lo + branch.range.width() * index as f64 / subdivisions as f64; points.push(graph.geometry.pcurve_at(branch.pcurves[side], parameter)?); } + Ok(polygon_centroid(&points)) +} + +pub(crate) fn polygon_centroid(points: &[[f64; 2]]) -> [f64; 2] { let mut twice_area = 0.0; let mut centroid = [0.0; 2]; for index in 0..points.len() { @@ -157,15 +161,15 @@ pub(crate) fn intersection_loop_sample( centroid[1] += (a[1] + b[1]) * cross; } if twice_area.abs() <= f64::EPSILON { - return Ok([ + return [ points.iter().map(|point| point[0]).sum::() / points.len() as f64, points.iter().map(|point| point[1]).sum::() / points.len() as f64, - ]); + ]; } - Ok([ + [ centroid[0] / (3.0 * twice_area), centroid[1] / (3.0 * twice_area), - ]) + ] } pub(crate) fn intersection_loop_bounds( diff --git a/main/opengeometry/src/operations/modifying/boolean/assembly/mod.rs b/main/opengeometry/src/operations/modifying/boolean/assembly/mod.rs index 83025ecb..de723bbe 100644 --- a/main/opengeometry/src/operations/modifying/boolean/assembly/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/assembly/mod.rs @@ -5,6 +5,7 @@ mod cylinder_faces; mod loop_pcurves; mod provenance; mod shell_components; +mod sphere_loop_chart; mod sphere_patch; mod welded_vertex; @@ -13,9 +14,9 @@ pub(super) use append::{ }; pub(super) use box_faces::add_box_faces; pub(super) use closed_loop::{ - add_closed_intersection_loop, add_complement_input, add_hole_loop, add_patch_face, - add_patch_loop, append_closed_branch_geometry, intersection_definition, loop_containment, - loop_regions, loop_selection, loop_sense, merge_selected_shells, reframe_sphere_intersection, + add_closed_intersection_loop, add_complement_input, add_hole_lifted_into_face, add_hole_loop, + add_patch_face, add_patch_loop, append_closed_branch_geometry, intersection_definition, + loop_containment, loop_regions, loop_selection, loop_sense, merge_selected_shells, retained_definition, widen_trim_bounds, ClosedBranchEdge, LoopRegion, LoopSides, }; pub(super) use cylinder_faces::{ @@ -31,5 +32,8 @@ pub(super) use provenance::{ reverse_face, source, }; pub(super) use shell_components::{assign_shell_faces, enclosing_solid}; +pub(super) use sphere_loop_chart::{ + reframe_sphere_intersection, sphere_loop_chart, SphereLoopChart, +}; pub(super) use sphere_patch::{append_sphere, patch, SpherePatch}; pub(super) use welded_vertex::add_welded_vertex; diff --git a/main/opengeometry/src/operations/modifying/boolean/assembly/sphere_loop_chart.rs b/main/opengeometry/src/operations/modifying/boolean/assembly/sphere_loop_chart.rs new file mode 100644 index 00000000..c3e12929 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/assembly/sphere_loop_chart.rs @@ -0,0 +1,191 @@ +use super::loop_pcurves::polygon_centroid; +use crate::brep::{ + unit, Frame3, GeometryError, GeometryStore, IntersectionDefinition, Surface, SurfaceGeometry, +}; +use crate::math::{add, cross, dot, scale, Interval, Point3}; + +struct SphereCandidate { + score: f64, + frame: Frame3, + coordinates: Vec<[f64; 2]>, +} + +pub(crate) struct SphereLoopChart { + pub(crate) frame: Frame3, + pub(crate) radius: f64, + coordinates: Vec<[f64; 2]>, +} + +impl SphereLoopChart { + fn surface(&self) -> SurfaceGeometry { + SurfaceGeometry::Sphere { + frame: self.frame, + radius: self.radius, + } + } + + pub(crate) fn patch_sample(&self) -> Result { + self.surface().point_at(polygon_centroid(&self.coordinates)) + } +} + +pub(crate) fn sphere_loop_chart( + geometry: &GeometryStore, + definition: &IntersectionDefinition, + side: usize, +) -> Result { + let (frame, radius) = match geometry.surface(definition.surfaces[side])? { + SurfaceGeometry::Sphere { frame, radius } => (*frame, *radius), + _ => { + return Err(GeometryError::CoverageGap { + families: ["periodic winding".into(), "non-spherical support".into()], + }) + } + }; + let SphereCandidate { + frame, coordinates, .. + } = best_sphere_candidate(frame, radius, definition)?.ok_or_else(winding_gap)?; + centred_on_half_turn(frame, radius, coordinates) +} + +fn winding_gap() -> GeometryError { + GeometryError::CoverageGap { + families: ["sphere".into(), "unresolved periodic winding".into()], + } +} + +fn best_sphere_candidate( + frame: Frame3, + radius: f64, + definition: &IntersectionDefinition, +) -> Result, GeometryError> { + let references = [frame.x, frame.y, frame.z]; + let mut best: Option = None; + for i in -1..=1 { + for j in -1..=1 { + for k in -1..=1 { + if [i, j, k] == [0, 0, 0] { + continue; + } + let axis = unit(frame.vector([i as f64, j as f64, k as f64]))?; + let Some(candidate) = + scored_sphere_candidate(frame, radius, references, axis, definition)? + else { + continue; + }; + if best + .as_ref() + .is_none_or(|best| candidate.score < best.score) + { + best = Some(candidate); + } + } + } + } + Ok(best) +} + +fn centred_on_half_turn( + frame: Frame3, + radius: f64, + mut coordinates: Vec<[f64; 2]>, +) -> Result { + let (lo, hi) = coordinates + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), uv| { + (lo.min(uv[0]), hi.max(uv[0])) + }); + if hi - lo >= std::f64::consts::TAU { + return Err(winding_gap()); + } + let angle = 0.5 * (lo + hi) - std::f64::consts::PI; + let (sin, cos) = angle.sin_cos(); + let x = add(scale(frame.x, cos), scale(frame.y, sin)); + let frame = Frame3 { + origin: frame.origin, + x, + y: cross(frame.z, x), + z: frame.z, + }; + frame.validate()?; + for uv in &mut coordinates { + uv[0] -= angle; + } + Ok(SphereLoopChart { + frame, + radius, + coordinates, + }) +} + +pub(crate) fn reframe_sphere_intersection( + geometry: &mut GeometryStore, + definition: &mut IntersectionDefinition, + side: usize, + chart: &SphereLoopChart, +) -> Result<(), GeometryError> { + geometry.surfaces[definition.surfaces[side] as usize] = chart.surface(); + let coordinates = &chart.coordinates; + for (anchor, uv) in definition.anchors.iter_mut().zip(coordinates) { + if side == 0 { + anchor.uv_a = *uv; + } else { + anchor.uv_b = *uv; + } + } + let offset = side * 2; + for (index, tube) in definition.uv_tubes.iter_mut().enumerate() { + for axis in 0..2 { + let a = coordinates[index][axis]; + let b = coordinates[index + 1][axis]; + let padding = (a - b) + .abs() + .max(definition.residual_tolerance / chart.radius) + * 2.0; + tube[offset + axis] = Interval::new(a.min(b) - padding, a.max(b) + padding)?; + } + } + definition.validate(geometry) +} + +fn scored_sphere_candidate( + frame: Frame3, + radius: f64, + references: [Point3; 3], + axis: Point3, + definition: &IntersectionDefinition, +) -> Result, GeometryError> { + let reference = references + .into_iter() + .min_by(|left, right| dot(*left, axis).abs().total_cmp(&dot(*right, axis).abs())) + .ok_or_else(|| GeometryError::InvalidGeometry("no reference axes".into()))?; + let frame = Frame3::from_axis(frame.origin, axis, reference)?; + let candidate = SurfaceGeometry::Sphere { frame, radius }; + let mut coordinates = Vec::with_capacity(definition.anchors.len()); + let mut hint = None; + for anchor in &definition.anchors { + let Ok(uv) = candidate.project(anchor.point, hint) else { + return Ok(None); + }; + coordinates.push(uv); + hint = Some(uv); + } + let Some(last) = coordinates.last() else { + return Ok(None); + }; + let longitude_span = (last[0] - coordinates[0][0]).abs(); + let pole_proximity = coordinates + .iter() + .map(|uv| uv[1].abs()) + .fold(0.0_f64, f64::max); + if longitude_span >= std::f64::consts::PI + || pole_proximity >= std::f64::consts::FRAC_PI_2 - 1.0e-6 + { + return Ok(None); + } + Ok(Some(SphereCandidate { + score: pole_proximity + longitude_span, + frame, + coordinates, + })) +} diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/single_closed_loop.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/single_closed_loop.rs index 7bebd7c2..394f6326 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/single_closed_loop.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/single_closed_loop.rs @@ -1,6 +1,6 @@ use crate::brep::{ - Accuracy, BrepEnvelope, GeometryError, GeometryStore, IntersectionDefinition, IntersectionSide, - PcurveGeometry, SurfaceGeometry, + Accuracy, BrepEnvelope, CurveGeometry, GeometryError, GeometryStore, IntersectionDefinition, + IntersectionSide, PcurveGeometry, SurfaceGeometry, }; use crate::intersection::{intersect_breps, IntersectionBranch, IntersectionGraph}; use crate::math::{norm, sub}; @@ -8,11 +8,12 @@ use crate::operations::modifying::boolean::assembly::{ add_complement_input, add_hole_loop, add_patch_face, add_patch_loop, append_closed_branch_geometry, finish_analytic_result, intersection_definition, loop_containment, loop_regions, loop_sense, merge_selected_shells, pcurve_support_surface, - pcurve_winding, reframe_sphere_intersection, reverse_face, ClosedBranchEdge, LoopRegion, - LoopSides, + pcurve_winding, reframe_sphere_intersection, reverse_face, sphere_loop_chart, ClosedBranchEdge, + LoopRegion, LoopSides, SphereLoopChart, }; -use crate::operations::modifying::boolean::operands::required_id; +use crate::operations::modifying::boolean::operands::{full_sphere, required_id}; use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; +use crate::primitives; pub(crate) fn generic_single_closed_loop_boolean( a: &BrepEnvelope, @@ -43,7 +44,7 @@ pub(crate) fn generic_single_closed_loop_boolean( if norm(sub(branch.endpoints[0], branch.endpoints[1])) > accuracy.intersection { return Ok(None); } - let Some(windings) = supported_windings(graph, branch)? else { + let Some(charts) = winding_sphere_charts(graph, branch)? else { return Ok(None); }; let inputs = [a, b]; @@ -53,13 +54,17 @@ pub(crate) fn generic_single_closed_loop_boolean( b, graph, branch, + &charts, "closed SSI loop interior is unclassified", )?; let (regions, reverse_selected) = loop_regions(operation, true, loop_inside_other); - + let Some(charted) = charted_complements(inputs, &charts, regions)? else { + return Ok(None); + }; + let complements = [0, 1].map(|side| charted[side].as_ref().unwrap_or(inputs[side])); let mut out = BrepEnvelope::new(id, accuracy)?; let (mut selected_faces, selected_surfaces) = - add_selected_sides(&mut out, graph, inputs, face_ids, regions)?; + add_selected_sides(&mut out, graph, complements, face_ids, regions)?; let sides = LoopSides { inputs, face_ids, @@ -71,7 +76,7 @@ pub(crate) fn generic_single_closed_loop_boolean( &mut out, graph, branch, - windings, + &charts, sides.surfaces, accuracy.geometric, )?; @@ -124,6 +129,78 @@ fn supported_windings( Ok(Some(windings)) } +fn winding_sphere_charts( + graph: &IntersectionGraph, + branch: &IntersectionBranch, +) -> Result; 2]>, GeometryError> { + let Some(windings) = supported_windings(graph, branch)? else { + return Ok(None); + }; + let mut charts = [None, None]; + for side in 0..2 { + if windings[side] == [0, 0] { + continue; + } + let CurveGeometry::Intersection { definition } = + graph.geometry.curves[branch.curve as usize] + else { + return Err(GeometryError::InvalidGeometry( + "winding closed loop requires an intersection curve".into(), + )); + }; + charts[side] = Some(sphere_loop_chart( + &graph.geometry, + &graph.geometry.intersections[definition as usize], + intersection_pcurve_side(&graph.geometry, branch.pcurves[side])?, + )?); + } + Ok(Some(charts)) +} + +fn charted_complements( + inputs: [&BrepEnvelope; 2], + charts: &[Option; 2], + regions: [LoopRegion; 2], +) -> Result; 2]>, GeometryError> { + let mut charted = [None, None]; + for side in 0..2 { + let Some(chart) = &charts[side] else { + continue; + }; + if regions[side] != LoopRegion::Complement { + continue; + } + if full_sphere(inputs[side]).is_err() { + return Ok(None); + } + let mut sphere = primitives::sphere( + inputs[side].id.clone(), + chart.frame, + chart.radius, + inputs[side].accuracy, + )?; + sphere.topology.faces[0].key = inputs[side].topology.faces[0].key.clone(); + charted[side] = Some(sphere); + } + Ok(Some(charted)) +} + +fn intersection_pcurve_side(geometry: &GeometryStore, pcurve: u32) -> Result { + match geometry.pcurves.get(pcurve as usize) { + Some(PcurveGeometry::IntersectionSide { + side: IntersectionSide::A, + .. + }) => Ok(0), + Some(PcurveGeometry::IntersectionSide { + side: IntersectionSide::B, + .. + }) => Ok(1), + _ => Err(GeometryError::InvalidGeometry( + "winding intersection loop is missing its support pcurve".into(), + )), + } +} + fn add_selected_sides( out: &mut BrepEnvelope, graph: &IntersectionGraph, @@ -163,7 +240,7 @@ fn add_intersection_edge( out: &mut BrepEnvelope, graph: &IntersectionGraph, branch: &IntersectionBranch, - windings: [[i32; 2]; 2], + charts: &[Option; 2], selected_surfaces: [Option; 2], geometric: f64, ) -> Result { @@ -178,7 +255,7 @@ fn add_intersection_edge( graph, branch, definition, - windings, + charts, selected_surfaces, ) }, @@ -191,7 +268,7 @@ fn retarget_loop_definition( graph: &IntersectionGraph, branch: &IntersectionBranch, definition: u32, - windings: [[i32; 2]; 2], + charts: &[Option; 2], selected_surfaces: [Option; 2], ) -> Result { let mut definition_geometry = intersection_definition(graph, definition)?; @@ -208,25 +285,15 @@ fn retarget_loop_definition( })?, ]; for side in 0..2 { - if windings[side] == [0, 0] { + let Some(chart) = &charts[side] else { continue; - } - let definition_side = match graph.geometry.pcurves[branch.pcurves[side] as usize] { - PcurveGeometry::IntersectionSide { - side: IntersectionSide::A, - .. - } => 0, - PcurveGeometry::IntersectionSide { - side: IntersectionSide::B, - .. - } => 1, - _ => { - return Err(GeometryError::InvalidGeometry( - "winding intersection loop is missing its support pcurve".into(), - )) - } }; - reframe_sphere_intersection(geometry, &mut definition_geometry, definition_side)?; + reframe_sphere_intersection( + geometry, + &mut definition_geometry, + intersection_pcurve_side(&graph.geometry, branch.pcurves[side])?, + chart, + )?; } Ok(definition_geometry) } diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/sphere_cylinder/offset.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/sphere_cylinder/offset.rs index 7dbae61e..b73ef135 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/sphere_cylinder/offset.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/sphere_cylinder/offset.rs @@ -6,9 +6,9 @@ use crate::brep::{ use crate::intersection::{intersect_faces, FaceView, IntersectionBranch, IntersectionGraph}; use crate::math::{norm, sub}; use crate::operations::modifying::boolean::assembly::{ - add_closed_intersection_loop, add_complement_input, finish_analytic_result, - intersection_loop_bounds, loop_containment, loop_regions, loop_sense, merge_selected_shells, - reverse_face, widen_trim_bounds, LoopRegion, + add_closed_intersection_loop, add_complement_input, add_hole_lifted_into_face, + finish_analytic_result, intersection_loop_bounds, loop_containment, loop_regions, loop_sense, + merge_selected_shells, pcurve_support_surface, pcurve_winding, reverse_face, LoopRegion, }; use crate::operations::modifying::boolean::operands::{ brep_face_source, required_id, CylinderInput, SphereInput, @@ -78,6 +78,7 @@ pub(super) fn offset_sphere_cylinder_boolean( cylinder.brep, &graph, branch, + &[None, None], "sphere/cylinder intersection loop interior is unclassified", )?; let (regions, reverse_selected) = loop_regions(operation, sphere_is_a, loop_inside_other); @@ -114,6 +115,14 @@ fn single_closed_branch( }) } }; + for pcurve in branch.pcurves { + let surface = pcurve_support_surface(&graph.geometry, pcurve)?; + if pcurve_winding(&graph.geometry, surface, pcurve, branch.range)? != [0, 0] { + return Err(GeometryError::CoverageGap { + families: ["sphere".into(), "cylinder loop winding the sphere".into()], + }); + } + } Ok((branch, curve_definition)) } @@ -259,13 +268,6 @@ fn add_loop_sides( let (pcurve, sense) = side_pcurve(classified, side, loop_edge.definition)?; if classified.regions[side] == LoopRegion::Complement { let face = required_id(selected_faces[side])?; - let loop_bounds = intersection_loop_bounds( - classified.graph, - classified.branch, - side, - out.accuracy.intersection, - )?; - widen_trim_bounds(out, face, loop_bounds)?; let loop_id = add_closed_intersection_loop( out, loop_edge.edge, @@ -275,7 +277,8 @@ fn add_loop_sides( sense, true, )?; - out.topology.faces[face as usize].trim.holes.push(loop_id); + let tolerance = out.accuracy.intersection; + add_hole_lifted_into_face(out, face, loop_id, tolerance)?; } else { let face = out.topology.faces.len() as u32; let loop_id = add_closed_intersection_loop( diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/closed_loop_windings.rs b/main/opengeometry/src/operations/modifying/boolean/tests/closed_loop_windings.rs new file mode 100644 index 00000000..f5562ea4 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/tests/closed_loop_windings.rs @@ -0,0 +1,270 @@ +use super::support::{ + assert_classifies, assert_seam_coverage_gap, cylinder, sphere, volume_at_deflection, +}; +use crate::brep::{BrepEnvelope, Frame3}; +use crate::math::{add, scale}; +use crate::operations::modifying::boolean::dispatch::{boolean_brep, boolean_brep_with_handlers}; +use crate::operations::modifying::boolean::types::BooleanOp; +use crate::primitives; +use crate::query::PointClassification::{Inside as In, Outside as Out}; +use crate::tessellation::tessellate; + +fn cone_and_sphere_with_winding_loop() -> (BrepEnvelope, BrepEnvelope) { + let accuracy = cylinder("accuracy", 0.0, 1.0, false).accuracy; + let cone = primitives::cone("cone".into(), Frame3::IDENTITY, 2.0, 2.0, accuracy).unwrap(); + let sphere = primitives::sphere( + "sphere".into(), + Frame3 { + origin: [1.1, 0.0, 0.8], + ..Frame3::IDENTITY + }, + 0.35, + accuracy, + ) + .unwrap(); + (cone, sphere) +} + +fn cylinder_and_sphere_with_winding_loop() -> (BrepEnvelope, BrepEnvelope) { + let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy; + let cylinder = + primitives::cylinder("cylinder".into(), Frame3::IDENTITY, 1.0, 2.0, accuracy).unwrap(); + let sphere = primitives::sphere( + "sphere".into(), + Frame3::from_axis([0.95, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]).unwrap(), + 0.3, + accuracy, + ) + .unwrap(); + (cylinder, sphere) +} + +#[test] +fn generic_closed_loop_handles_periodic_winding() { + let (cone, sphere) = cone_and_sphere_with_winding_loop(); + let result = boolean_brep(&cone, &sphere, BooleanOp::Intersection, "winding".into()).unwrap(); + result.brep.validate().unwrap(); + assert_classifies( + &result.brep, + &[ + ([1.0, 0.0, 0.8], In), + ([0.8, 0.0, 0.8], In), + ([0.6, 0.0, 0.8], Out), + ([1.4, 0.0, 0.9], Out), + ], + ); + tessellate(&result.brep, 0.01, 2_000_000).unwrap(); + let sphere_volume = 4.0 / 3.0 * std::f64::consts::PI * 0.35_f64.powi(3); + let inside = volume_at_deflection(&result.brep, 0.002); + assert!( + inside > 0.5 * sphere_volume && inside < sphere_volume, + "{inside} of {sphere_volume}" + ); +} + +#[test] +fn generic_closed_loop_subtracts_a_cone_from_a_sphere_with_winding_loop() { + let (cone, sphere) = cone_and_sphere_with_winding_loop(); + let result = boolean_brep(&sphere, &cone, BooleanOp::Subtraction, "winding".into()).unwrap(); + result.brep.validate().unwrap(); + assert_classifies( + &result.brep, + &[ + ([1.4, 0.0, 0.9], In), + ([1.0, 0.0, 0.8], Out), + ([0.6, 0.0, 0.8], Out), + ], + ); + let intersection = + boolean_brep(&cone, &sphere, BooleanOp::Intersection, "winding".into()).unwrap(); + let whole = volume_at_deflection(&sphere, 0.002); + let outside = volume_at_deflection(&result.brep, 0.002); + let inside = volume_at_deflection(&intersection.brep, 0.002); + assert!( + (inside + outside - whole).abs() < 0.01 * whole, + "{inside} + {outside} != {whole}" + ); +} + +#[test] +fn generic_closed_loop_intersects_a_torus_with_a_sphere_with_winding_loop() { + let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy; + let torus = primitives::torus("torus".into(), Frame3::IDENTITY, 2.0, 0.5, accuracy).unwrap(); + for (centre, axis) in [ + ([2.0, 0.0, 0.45], [0.0, 0.0, 1.0]), + ([2.45, 0.0, 0.0], [1.0, 0.0, 0.0]), + ] { + let sphere = primitives::sphere( + "sphere".into(), + Frame3::from_axis(centre, axis, [0.0, 1.0, 0.0]).unwrap(), + 0.2, + accuracy, + ) + .unwrap(); + let result = + boolean_brep(&torus, &sphere, BooleanOp::Intersection, "winding".into()).unwrap(); + result.brep.validate().unwrap(); + assert_classifies( + &result.brep, + &[ + (add(centre, scale(axis, -0.1)), In), + (add(centre, scale(axis, 0.17)), Out), + (add(centre, scale(axis, -0.3)), Out), + ], + ); + let sphere_volume = 4.0 / 3.0 * std::f64::consts::PI * 0.2_f64.powi(3); + let inside = volume_at_deflection(&result.brep, 0.001); + assert!( + inside > 0.5 * sphere_volume && inside < sphere_volume, + "{centre:?}: {inside} of {sphere_volume}" + ); + } +} + +#[test] +fn sphere_cylinder_loop_winding_the_sphere_uses_the_generic_closed_loop() { + let (cylinder, sphere) = cylinder_and_sphere_with_winding_loop(); + let in_both = [0.9, 0.0, 1.0]; + let sphere_only = [1.15, 0.0, 1.0]; + let cylinder_only = [0.5, 0.0, 1.0]; + let intersection = + boolean_brep(&cylinder, &sphere, BooleanOp::Intersection, "i".into()).unwrap(); + assert_classifies( + &intersection.brep, + &[(in_both, In), (sphere_only, Out), (cylinder_only, Out)], + ); + let difference = boolean_brep(&sphere, &cylinder, BooleanOp::Subtraction, "d".into()).unwrap(); + assert_classifies( + &difference.brep, + &[(in_both, Out), (sphere_only, In), (cylinder_only, Out)], + ); + let whole = volume_at_deflection(&sphere, 0.002); + let inside = volume_at_deflection(&intersection.brep, 0.002); + let outside = volume_at_deflection(&difference.brep, 0.002); + assert!( + (inside + outside - whole).abs() < 0.01 * whole, + "{inside} + {outside} != {whole}" + ); +} + +#[test] +fn generic_closed_loop_lifts_the_host_hole_into_its_face_window() { + let accuracy = cylinder("accuracy", 0.0, 1.0, false).accuracy; + let cone = primitives::cone("cone".into(), Frame3::IDENTITY, 2.0, 2.0, accuracy).unwrap(); + let cone_volume = volume_at_deflection(&cone, 0.002); + for (centre, in_both, sphere_only, cone_only, neither) in [ + ( + [0.0, 0.9, 0.9], + [0.0, 0.8, 0.9], + [0.0, 1.2, 0.95], + [0.0, 0.4, 0.9], + [0.0, 1.3, 1.1], + ), + ( + [0.0, 1.42, 1.0], + [0.0, 1.196, 0.776], + [0.0, 1.5, 1.0], + [0.0, 0.5, 0.8], + [0.0, 1.8, 1.0], + ), + ] { + let sphere = primitives::sphere( + "sphere".into(), + Frame3 { + origin: centre, + ..Frame3::IDENTITY + }, + 0.35, + accuracy, + ) + .unwrap(); + let sphere_volume = volume_at_deflection(&sphere, 0.002); + let union = boolean_brep(&cone, &sphere, BooleanOp::Union, "u".into()).unwrap(); + let intersection = + boolean_brep(&cone, &sphere, BooleanOp::Intersection, "i".into()).unwrap(); + let difference = boolean_brep(&cone, &sphere, BooleanOp::Subtraction, "d".into()).unwrap(); + for (result, expected) in [ + (&union, [In, In, In, Out]), + (&intersection, [In, Out, Out, Out]), + (&difference, [Out, Out, In, Out]), + ] { + assert_classifies( + &result.brep, + &[ + (in_both, expected[0]), + (sphere_only, expected[1]), + (cone_only, expected[2]), + (neither, expected[3]), + ], + ); + } + let union = volume_at_deflection(&union.brep, 0.002); + let intersection = volume_at_deflection(&intersection.brep, 0.002); + let difference = volume_at_deflection(&difference.brep, 0.002); + assert!( + (union + intersection - cone_volume - sphere_volume).abs() < 2e-3 * cone_volume, + "{centre:?}: {union} + {intersection} != {cone_volume} + {sphere_volume}" + ); + assert!( + (difference + intersection - cone_volume).abs() < 2e-3 * cone_volume, + "{centre:?}: {difference} + {intersection} != {cone_volume}" + ); + } +} + +#[test] +fn winding_loop_across_the_host_seam_is_a_coverage_gap() { + let (cone, sphere_on_cone) = cone_and_sphere_with_winding_loop(); + let (cylinder, sphere_on_cylinder) = cylinder_and_sphere_with_winding_loop(); + for (host, sphere, face) in [ + (&cone, &sphere_on_cone, "cone:lateral"), + (&cylinder, &sphere_on_cylinder, "cylinder:lateral"), + ] { + for operation in [BooleanOp::Union, BooleanOp::Subtraction] { + assert_seam_coverage_gap( + boolean_brep(host, sphere, operation, format!("{operation:?}")), + face, + ); + } + } +} + +#[test] +fn generic_multiple_closed_loops_keep_a_ring_poking_out_of_a_torus_off_its_seams() { + let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy; + let host = primitives::torus("host".into(), Frame3::IDENTITY, 3.0, 1.0, accuracy).unwrap(); + let ring = primitives::torus( + "ring".into(), + Frame3 { + origin: [0.0, 3.0, 0.7], + ..Frame3::IDENTITY + }, + 0.5, + 0.2, + accuracy, + ) + .unwrap(); + let ring_only = [0.0, 3.6047, 0.8465]; + let in_both = [0.0, 3.5, 0.7]; + let host_only = [0.0, 3.0, 0.0]; + let neither = [0.0, 3.5, 1.3]; + for (operation, expected) in [ + (BooleanOp::Union, [In, In, In, Out]), + (BooleanOp::Intersection, [Out, In, Out, Out]), + (BooleanOp::Subtraction, [Out, Out, In, Out]), + ] { + let (result, handlers) = + boolean_brep_with_handlers(&host, &ring, operation, format!("{operation:?}")).unwrap(); + assert_eq!(handlers, ["generic_multiple_closed_loops_boolean"]); + result.brep.validate().unwrap(); + assert_classifies( + &result.brep, + &[ + (ring_only, expected[0]), + (in_both, expected[1]), + (host_only, expected[2]), + (neither, expected[3]), + ], + ); + } +} diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/generic.rs b/main/opengeometry/src/operations/modifying/boolean/tests/generic.rs index c2b761eb..b4955554 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/generic.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/generic.rs @@ -1,4 +1,4 @@ -use super::support::{cylinder, sphere}; +use super::support::{assert_seam_coverage_gap, cylinder, sphere, volume_at_deflection}; use crate::brep::{Accuracy, CurveGeometry, FaceRole, Frame3}; use crate::operations::modifying::boolean::dispatch::boolean_brep; use crate::operations::modifying::boolean::types::BooleanOp; @@ -89,18 +89,19 @@ fn generic_closed_loop_imprints_an_off_axis_sphere_cone_intersection() { accuracy, ) .unwrap(); - for operation in [ - BooleanOp::Union, - BooleanOp::Intersection, - BooleanOp::Subtraction, + for operation in [BooleanOp::Union, BooleanOp::Subtraction] { + assert_seam_coverage_gap( + boolean_brep(&cone, &sphere, operation, format!("{operation:?}")), + "cone:lateral", + ); + } + let mut volumes = Vec::new(); + for (operation, left, right) in [ + (BooleanOp::Intersection, &cone, &sphere), + (BooleanOp::Subtraction, &sphere, &cone), ] { - let result = boolean_brep( - &cone, - &sphere, - operation, - format!("sphere-cone-{operation:?}"), - ) - .unwrap_or_else(|error| panic!("{operation:?}: {error}")); + let result = boolean_brep(left, right, operation, format!("sphere-cone-{operation:?}")) + .unwrap_or_else(|error| panic!("{operation:?}: {error}")); assert_eq!(result.brep.solids.len(), 1); assert!(result .brep @@ -117,26 +118,13 @@ fn generic_closed_loop_imprints_an_off_axis_sphere_cone_intersection() { result.brep.validate().unwrap(); tessellate(&result.brep, 0.01, 2_000_000) .unwrap_or_else(|error| panic!("{operation:?} tessellation: {error}")); + volumes.push(volume_at_deflection(&result.brep, 0.002)); } -} - -#[test] -fn generic_closed_loop_handles_periodic_winding() { - let accuracy = cylinder("accuracy", 0.0, 1.0, false).accuracy; - let cone = primitives::cone("cone".into(), Frame3::IDENTITY, 2.0, 2.0, accuracy).unwrap(); - let sphere = primitives::sphere( - "sphere".into(), - Frame3 { - origin: [1.1, 0.0, 0.8], - ..Frame3::IDENTITY - }, - 0.35, - accuracy, - ) - .unwrap(); - let result = boolean_brep(&cone, &sphere, BooleanOp::Intersection, "winding".into()).unwrap(); - result.brep.validate().unwrap(); - tessellate(&result.brep, 0.01, 2_000_000).unwrap(); + let whole = volume_at_deflection(&sphere, 0.002); + assert!( + (volumes[0] + volumes[1] - whole).abs() < 0.01 * whole, + "{volumes:?} != {whole}" + ); } #[test] diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs b/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs index ce775760..59e1afc4 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs @@ -1,6 +1,7 @@ mod arc_extrusions_openings; mod arc_extrusions_splits; mod arched_band; +mod closed_loop_windings; mod conic_torus; mod cuboids; mod cuboids_curved_cuts; diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/sphere_cylinder.rs b/main/opengeometry/src/operations/modifying/boolean/tests/sphere_cylinder.rs index 315cd313..74dda686 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/sphere_cylinder.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/sphere_cylinder.rs @@ -1,4 +1,4 @@ -use super::support::{cylinder, volume}; +use super::support::{assert_seam_coverage_gap, cylinder, volume, volume_at_deflection}; use crate::brep::{CurveGeometry, FaceRole, Frame3, GeometryError, GeometryQuality}; use crate::exchange::export_step; use crate::operations::modifying::boolean::dispatch::boolean_brep; @@ -98,60 +98,61 @@ fn mixed_sphere_cylinder_containment_preserves_exact_cavity_supports() { accuracy, ) .unwrap(); - for (name, left, right, operation) in [ - ( - "crossing-union", - &cylinder_host, - &crossing, - BooleanOp::Union, - ), + for (name, operation, face) in [ + ("crossing-union", BooleanOp::Union, "cylinder:lateral"), ( "crossing-intersection", - &cylinder_host, - &crossing, BooleanOp::Intersection, + "crossing:skin", ), ( "crossing-cylinder-cut", - &cylinder_host, - &crossing, - BooleanOp::Subtraction, - ), - ( - "crossing-sphere-cut", - &crossing, - &cylinder_host, BooleanOp::Subtraction, + "cylinder:lateral", ), ] { - let result = boolean_brep(left, right, operation, name.into()) - .unwrap_or_else(|error| panic!("{name}: {error}")); - assert!( - matches!(result.report.quality, GeometryQuality::Analytic), - "{name}" + assert_seam_coverage_gap( + boolean_brep(&cylinder_host, &crossing, operation, name.into()), + face, ); - assert_eq!(result.brep.solids.len(), 1, "{name}"); - assert!(result.brep.topology.faces.len() >= 2, "{name}"); - assert!(!result.brep.topology.edges.is_empty(), "{name}"); - assert!( - result - .brep - .topology - .faces - .iter() - .all(|face| !face.provenance.sources.is_empty()), - "{name}" - ); - assert!(result + } + let result = boolean_brep( + &crossing, + &cylinder_host, + BooleanOp::Subtraction, + "crossing-sphere-cut".into(), + ) + .unwrap(); + assert!( + matches!(result.report.quality, GeometryQuality::Analytic), + "crossing-sphere-cut" + ); + assert_eq!(result.brep.solids.len(), 1, "crossing-sphere-cut"); + assert!(result.brep.topology.faces.len() >= 2, "crossing-sphere-cut"); + assert!( + !result.brep.topology.edges.is_empty(), + "crossing-sphere-cut" + ); + assert!( + result .brep - .geometry - .curves + .topology + .faces .iter() - .any(|curve| matches!(curve, CurveGeometry::Intersection { .. }))); - result.brep.validate().unwrap(); - tessellate(&result.brep, 0.01, 2_000_000) - .unwrap_or_else(|error| panic!("{name} tessellation: {error}")); - } + .all(|face| !face.provenance.sources.is_empty()), + "crossing-sphere-cut" + ); + assert!(result + .brep + .geometry + .curves + .iter() + .any(|curve| matches!(curve, CurveGeometry::Intersection { .. }))); + result.brep.validate().unwrap(); + tessellate(&result.brep, 0.01, 2_000_000) + .unwrap_or_else(|error| panic!("tessellation: {error}")); + let cap = volume_at_deflection(&result.brep, 0.002); + assert!((cap - 0.12248).abs() < 0.01 * 0.12248, "{cap}"); } #[test] diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/support.rs b/main/opengeometry/src/operations/modifying/boolean/tests/support.rs index a780aee1..d98c1fe0 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/support.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/support.rs @@ -1,8 +1,9 @@ -use crate::brep::{Accuracy, BrepEnvelope, Frame3}; +use crate::brep::{Accuracy, BrepEnvelope, Frame3, GeometryError}; use crate::math::{cross, dot, Point3}; use crate::operations::modifying::boolean::dispatch::boolean_brep_with_handlers; -use crate::operations::modifying::boolean::types::BooleanOp; +use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; use crate::primitives; +use crate::query::{classify_point, PointClassification}; use crate::tessellation::tessellate; pub(super) fn sphere(id: &str, center: Point3, radius: f64) -> BrepEnvelope { @@ -127,3 +128,28 @@ pub(super) fn assert_prismatic_volume( "{measured} != {expected}" ); } + +pub(super) fn assert_classifies(brep: &BrepEnvelope, points: &[(Point3, PointClassification)]) { + for (point, expected) in points { + assert_eq!( + classify_point(brep, *point).unwrap(), + *expected, + "{} at {point:?}", + brep.id + ); + } +} + +pub(super) fn assert_seam_coverage_gap(result: Result, face: &str) { + match result { + Err(GeometryError::CoverageGap { families }) => assert_eq!( + families, + [ + "closed intersection loop as a hole".to_string(), + format!("loop crossing the chart seam of face '{face}'"), + ] + ), + Err(error) => panic!("expected a seam coverage gap, got {error}"), + Ok(_) => panic!("expected a seam coverage gap, got a result"), + } +} diff --git a/main/opengeometry/tests/golden/booleans_spheres.rs b/main/opengeometry/tests/golden/booleans_spheres.rs index 64387cb4..00576572 100644 --- a/main/opengeometry/tests/golden/booleans_spheres.rs +++ b/main/opengeometry/tests/golden/booleans_spheres.rs @@ -235,6 +235,16 @@ fn torus_family() -> Vec { "poking", &poking, )); + let poking_off_seam = operands(|| { + let host = primitives::torus("host".into(), Frame3::IDENTITY, 3.0, 1.0, fine())?; + let ring = primitives::torus("ring".into(), at([0.0, 3.0, 0.7]), 0.5, 0.2, fine())?; + Ok((host, ring)) + }); + cases.extend(every_operation( + "booleans.torus.poking-ring-off-seam", + "poking-off-seam", + &poking_off_seam, + )); cases.extend(ring_hosts()); cases }