diff --git a/main/opengeometry/src/brep/builder/boundary.rs b/main/opengeometry/src/brep/builder/boundary.rs index 35668587..82a0a6c1 100644 --- a/main/opengeometry/src/brep/builder/boundary.rs +++ b/main/opengeometry/src/brep/builder/boundary.rs @@ -41,6 +41,15 @@ pub(crate) fn plane_boundary( axis_a: local(scale(c.x, *radius)), axis_b: local(scale(c.y, *radius)), }, + CurveGeometry::Ellipse { + frame: c, + major_radius, + minor_radius, + } => PcurveGeometry::Conic2 { + origin: local(sub(c.origin, frame.origin)), + axis_a: local(scale(c.x, *major_radius)), + axis_b: local(scale(c.y, *minor_radius)), + }, _ => { return Err(GeometryError::UnsupportedGeometry( "planar primitive boundary".into(), diff --git a/main/opengeometry/src/operations/modifying/boolean/batch/mod.rs b/main/opengeometry/src/operations/modifying/boolean/batch/mod.rs index dc5228b1..ef984ae4 100644 --- a/main/opengeometry/src/operations/modifying/boolean/batch/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/batch/mod.rs @@ -2,7 +2,10 @@ mod prismatic_profile; use super::assembly::{analytic_face_mappings, append_analytic_input}; use super::dispatch::{boolean_brep_outcome_with_trace, record_handler, HandlerId, Trace}; -use super::handlers::{boolean_rectilinear, subtract_vertical_arc_extrusion_batch}; +use super::handlers::{ + boolean_rectilinear, subtract_half_spaces_from_arc_extrusion, + subtract_vertical_arc_extrusion_batch, +}; use super::operands::{all_planar, full_cylinder, unique_sources}; use super::types::{BooleanOp, BooleanResult}; use crate::brep::{Accuracy, BrepEnvelope, GeometryError, PatchBounds, SurfaceGeometry}; @@ -107,6 +110,27 @@ fn planar_cutters_boolean( { return mixed_cutters_boolean(host, cutters, planar, cylinders, id, trace); } + if let Some(result) = arc_extrusion_batch(host, cutters, &id, trace)? { + return Ok(result); + } + check_disjoint_cutters(cutters, &bounds, accuracy)?; + let mut combined = BrepEnvelope::new(format!("{id}:cutters"), accuracy)?; + for cutter in cutters { + append_analytic_input(&mut combined, cutter)?; + } + combined.validate()?; + let result = boolean_rectilinear(host, &combined, BooleanOp::Subtraction, id)?; + let handlers = &mut trace.handlers; + handlers.push(HandlerId::BooleanRectilinear.as_str().into()); + Ok(result) +} + +fn arc_extrusion_batch( + host: &BrepEnvelope, + cutters: &[BrepEnvelope], + id: &str, + trace: &mut Trace, +) -> Result, GeometryError> { if host .geometry .surfaces @@ -117,27 +141,31 @@ fn planar_cutters_boolean( match subtract_vertical_arc_extrusion_batch( host, &cutters.iter().collect::>(), - id.clone(), + id.to_string(), ) { Ok(result) => { let handlers = &mut trace.handlers; handlers.push(HandlerId::SubtractVerticalArcExtrusionBatch.as_str().into()); - return Ok(result); + return Ok(Some(result)); + } + Err(GeometryError::CoverageGap { .. }) => {} + Err(error) => return Err(error), + } + match subtract_half_spaces_from_arc_extrusion( + host, + &cutters.iter().collect::>(), + id.to_string(), + ) { + Ok(result) => { + let name = HandlerId::SubtractHalfSpaceFromArcExtrusion.as_str(); + trace.handlers.extend(cutters.iter().map(|_| name.into())); + return Ok(Some(result)); } Err(GeometryError::CoverageGap { .. }) => {} Err(error) => return Err(error), } } - check_disjoint_cutters(cutters, &bounds, accuracy)?; - let mut combined = BrepEnvelope::new(format!("{id}:cutters"), accuracy)?; - for cutter in cutters { - append_analytic_input(&mut combined, cutter)?; - } - combined.validate()?; - let result = boolean_rectilinear(host, &combined, BooleanOp::Subtraction, id)?; - let handlers = &mut trace.handlers; - handlers.push(HandlerId::BooleanRectilinear.as_str().into()); - Ok(result) + Ok(None) } fn accuracy_and_bounds( diff --git a/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs b/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs index d98940bd..bea1f7b6 100644 --- a/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs +++ b/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs @@ -23,6 +23,7 @@ pub(crate) enum HandlerId { BooleanPlanarPolyhedra, BooleanSpheres, SubtractVerticalArcExtrusion, + SubtractHalfSpaceFromArcExtrusion, GenericTwoSidedCutterBandSubtraction, GenericMultipleClosedLoopsBoolean, GenericSingleClosedLoopBoolean, @@ -59,6 +60,7 @@ impl HandlerId { Self::BooleanPlanarPolyhedra => "boolean_planar_polyhedra", Self::BooleanSpheres => "boolean_spheres", Self::SubtractVerticalArcExtrusion => "subtract_vertical_arc_extrusion", + Self::SubtractHalfSpaceFromArcExtrusion => "subtract_half_space_from_arc_extrusion", Self::GenericTwoSidedCutterBandSubtraction => { "generic_two_sided_cutter_band_subtraction" } diff --git a/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs b/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs index 78d35198..9a3444de 100644 --- a/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs +++ b/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs @@ -8,6 +8,7 @@ use crate::operations::modifying::boolean::handlers::{ generic_non_intersecting_boolean, generic_single_closed_loop_boolean, generic_two_sided_cutter_band_subtraction, prismatic_curved_boolean, sphere_box_boolean, sphere_conic_containment, sphere_cylinder_boolean, sphere_torus_containment, + subtract_arc_extrusion_from_planar_solid, subtract_half_space_from_arc_extrusion, subtract_layered_extrusions, subtract_vertical_arc_extrusion, torus_box_containment, torus_conic_containment, torus_containment_boolean, torus_cylinder_containment, }; @@ -253,6 +254,33 @@ fn maybe_vertical_arc_extrusion( b: &BrepEnvelope, operation: BooleanOp, id: String, +) -> Result, GeometryError> { + maybe_arc_extrusion_cut(a, b, operation, || { + subtract_vertical_arc_extrusion(a, b, id) + }) +} + +fn maybe_half_space_arc_extrusion( + a: &BrepEnvelope, + b: &BrepEnvelope, + operation: BooleanOp, + id: String, +) -> Result, GeometryError> { + if all_planar(a) { + return maybe_arc_extrusion_cut(b, a, operation, || { + subtract_arc_extrusion_from_planar_solid(a, b, id) + }); + } + maybe_arc_extrusion_cut(a, b, operation, || { + subtract_half_space_from_arc_extrusion(a, b, id) + }) +} + +fn maybe_arc_extrusion_cut( + a: &BrepEnvelope, + b: &BrepEnvelope, + operation: BooleanOp, + cut: impl FnOnce() -> Result, ) -> Result, GeometryError> { if operation != BooleanOp::Subtraction || !all_planar(b) @@ -264,7 +292,7 @@ fn maybe_vertical_arc_extrusion( { return Ok(None); } - match subtract_vertical_arc_extrusion(a, b, id) { + match cut() { Ok(result) => Ok(Some(result)), Err(GeometryError::CoverageGap { .. }) => Ok(None), Err(error) => Err(error), @@ -276,6 +304,10 @@ const GENERIC_HANDLERS: &[GenericHandler] = &[ name: HandlerId::SubtractVerticalArcExtrusion, call: maybe_vertical_arc_extrusion, }, + GenericHandler { + name: HandlerId::SubtractHalfSpaceFromArcExtrusion, + call: maybe_half_space_arc_extrusion, + }, GenericHandler { name: HandlerId::GenericTwoSidedCutterBandSubtraction, call: generic_two_sided_cutter_band_subtraction, diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/cylinder_box/tests/tilt.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/cylinder_box/tests/tilt.rs index 4022a66c..ddc0d8cb 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/cylinder_box/tests/tilt.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/cylinder_box/tests/tilt.rs @@ -96,19 +96,15 @@ fn cuboid_across_or_along_the_axis_keeps_its_handler() { } #[test] -fn tilted_cuboid_minus_cylinder_and_their_union_stay_a_coverage_gap() { +fn tilted_cuboid_and_cylinder_union_stays_a_coverage_gap() { let cylinder = host(); let cuboid = tilted_cuboid(45.0); - for (a, b, operation) in [ - (&cuboid, &cylinder, BooleanOp::Subtraction), - (&cylinder, &cuboid, BooleanOp::Union), - ] { - let (result, handlers) = boolean_brep_outcome_with_handlers(a, b, operation, "out".into()); - assert!(handlers.is_empty(), "{handlers:?}"); - assert!( - matches!(result, Err(GeometryError::CoverageGap { .. })), - "{operation:?}: {:?}", - result.map(|_| ()) - ); - } + let (result, handlers) = + boolean_brep_outcome_with_handlers(&cylinder, &cuboid, BooleanOp::Union, "out".into()); + assert!(handlers.is_empty(), "{handlers:?}"); + assert!( + matches!(result, Err(GeometryError::CoverageGap { .. })), + "{:?}", + result.map(|_| ()) + ); } diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/build.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/build.rs new file mode 100644 index 00000000..c6a6e398 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/build.rs @@ -0,0 +1,481 @@ +use super::cut::extent; +use super::prism::{gap, Affine, Cap, Prism, Span}; +use crate::brep::{ + boundary, padded_uv_bounds, plane_boundary, unit, uv_line, Accuracy, BrepEnvelope, Builder, + CurveGeometry, FaceProvenance, FaceRole, Frame3, GeometryError, Orientation, PcurveGeometry, + SurfaceGeometry, Use, +}; +use crate::math::{add, cross, dot, norm, scale, sub, Interval, Point3}; +use crate::operations::modifying::boolean::assembly::face_provenance; +use crate::primitives::ProfileEdge; + +struct Conic { + curve: u32, + alpha: f64, + slope: f64, + middle: f64, + radius: f64, + lo: f64, +} + +struct CapEdge { + edge: u32, + forward: bool, + conic: Option, +} + +pub(super) struct Ring { + vertices: [Vec; 2], + edges: [Vec; 2], + generators: Vec, +} + +struct Side<'a> { + span: &'a Span, + ring: &'a Ring, + index: usize, + next: usize, +} + +pub(super) fn build( + prism: &Prism, + id: String, + accuracy: Accuracy, +) -> Result { + let mut builder = Builder::new(id, accuracy)?; + let rings = add_rings(&mut builder, prism)?; + add_sides(&mut builder, prism, &rings, FaceRole::Split, false)?; + add_cap(&mut builder, prism, &rings, 1)?; + add_cap(&mut builder, prism, &rings, 0)?; + builder.finish_solid() +} + +pub(super) fn add_rings(builder: &mut Builder, prism: &Prism) -> Result, GeometryError> { + prism + .loops + .iter() + .map(|spans| add_ring(builder, prism, spans)) + .collect() +} + +pub(super) fn add_sides( + builder: &mut Builder, + prism: &Prism, + rings: &[Ring], + role: FaceRole, + reversed: bool, +) -> Result, GeometryError> { + let mut faces = Vec::new(); + for (spans, ring) in prism.loops.iter().zip(rings) { + for (index, span) in spans.iter().enumerate() { + let side = Side { + span, + ring, + index, + next: (index + 1) % spans.len(), + }; + let provenance = face_provenance(vec![span.source.clone()], role, reversed); + faces.push(match span.edge { + ProfileEdge::Line { .. } => add_plane_side(builder, prism, &side, provenance)?, + ProfileEdge::Arc { .. } => add_cylinder_side(builder, prism, &side, provenance)?, + }); + } + } + Ok(faces) +} + +fn cap_point(prism: &Prism, cap: usize, q: [f64; 2]) -> Point3 { + add( + prism.frame.point([q[0], q[1], 0.0]), + scale(prism.frame.z, prism.caps[cap].height.at(q)), + ) +} + +fn sense(forward: bool) -> Orientation { + if forward { + Orientation::Forward + } else { + Orientation::Reverse + } +} + +fn add_ring(builder: &mut Builder, prism: &Prism, spans: &[Span]) -> Result { + let starts = spans + .iter() + .map(|span| span.edge.endpoints().0) + .collect::>(); + let vertices = [0, 1].map(|cap| { + starts + .iter() + .map(|q| builder.vertex(cap_point(prism, cap, *q))) + .collect::>() + }); + let mut edges = [Vec::new(), Vec::new()]; + for cap in 0..2 { + for (index, span) in spans.iter().enumerate() { + let ends = [starts[index], starts[(index + 1) % spans.len()]]; + edges[cap].push(add_cap_edge(builder, prism, cap, &span.edge, ends)?); + } + } + let mut generators = Vec::with_capacity(spans.len()); + for q in &starts { + let length = prism.caps[1].height.at(*q) - prism.caps[0].height.at(*q); + generators.push(builder.edge( + CurveGeometry::Line { + origin: cap_point(prism, 0, *q), + direction: prism.frame.z, + }, + Interval::new(0.0, length)?, + false, + )); + } + Ok(Ring { + vertices, + edges, + generators, + }) +} + +fn add_cap_edge( + builder: &mut Builder, + prism: &Prism, + cap: usize, + edge: &ProfileEdge, + ends: [[f64; 2]; 2], +) -> Result { + let ProfileEdge::Arc { + center, + radius, + start_angle, + sweep_angle, + } = *edge + else { + let [from, to] = ends.map(|q| cap_point(prism, cap, q)); + let edge = builder.edge( + CurveGeometry::Line { + origin: from, + direction: unit(sub(to, from))?, + }, + Interval::new(0.0, norm(sub(to, from)))?, + false, + ); + return Ok(CapEdge { + edge, + forward: true, + conic: None, + }); + }; + let height = prism.caps[cap].height; + let slope = height.g[0].hypot(height.g[1]); + let alpha = if slope > 1.0e-14 { + height.g[1].atan2(height.g[0]) + } else { + 0.0 + }; + let [lo, hi] = [start_angle - alpha, start_angle + sweep_angle - alpha]; + let curve = builder.brep.geometry.curves.len() as u32; + let geometry = cap_curve(prism, cap, center, radius, [alpha, slope])?; + let edge = builder.edge(geometry, Interval::new(lo.min(hi), lo.max(hi))?, false); + Ok(CapEdge { + edge, + forward: sweep_angle > 0.0, + conic: Some(Conic { + curve, + alpha, + slope: if slope > 1.0e-14 { slope } else { 0.0 }, + middle: height.at(center), + radius, + lo: lo.min(hi), + }), + }) +} + +fn cap_curve( + prism: &Prism, + cap: usize, + center: [f64; 2], + radius: f64, + [alpha, slope]: [f64; 2], +) -> Result { + let frame = prism.frame; + let x = frame.vector([alpha.cos(), alpha.sin(), 0.0]); + let y = frame.vector([-alpha.sin(), alpha.cos(), 0.0]); + let origin = cap_point(prism, cap, center); + if slope <= 1.0e-14 { + return Ok(CurveGeometry::Circle { + frame: Frame3 { + origin, + x, + y, + z: frame.z, + }, + radius, + }); + } + let major = unit(add(x, scale(frame.z, slope)))?; + Ok(CurveGeometry::Ellipse { + frame: Frame3 { + origin, + x: major, + y, + z: cross(major, y), + }, + major_radius: radius * slope.hypot(1.0), + minor_radius: radius, + }) +} + +fn side_steps(side: &Side<'_>) -> [(u32, u32, u32, Orientation); 4] { + let Side { + ring, index, next, .. + } = *side; + let [low, high] = [&ring.edges[0][index], &ring.edges[1][index]]; + let [bottom, top] = [&ring.vertices[0], &ring.vertices[1]]; + [ + (low.edge, bottom[index], bottom[next], sense(low.forward)), + ( + ring.generators[next], + bottom[next], + top[next], + Orientation::Forward, + ), + (high.edge, top[next], top[index], sense(!high.forward)), + ( + ring.generators[index], + top[index], + bottom[index], + Orientation::Reverse, + ), + ] +} + +pub(super) fn add_face( + builder: &mut Builder, + label: &str, + surface: SurfaceGeometry, + bounds: [[f64; 2]; 2], + loops: Vec>, + provenance: FaceProvenance, +) -> Result { + let face = builder.brep.topology.faces.len(); + let mut loops = loops.into_iter(); + let outer = loops.next().unwrap_or_default(); + builder.face_with_holes( + &format!("{face}:{label}"), + surface, + bounds, + outer, + loops.collect(), + )?; + builder.brep.topology.faces[face].provenance = provenance; + Ok(face as u32) +} + +fn add_plane_side( + builder: &mut Builder, + prism: &Prism, + side: &Side<'_>, + provenance: FaceProvenance, +) -> Result { + let (from, to) = side.span.edge.endpoints(); + let along = unit(prism.frame.vector([to[0] - from[0], to[1] - from[1], 0.0]))?; + let frame = Frame3 { + origin: prism.frame.point([from[0], from[1], 0.0]), + x: along, + y: prism.frame.z, + z: cross(along, prism.frame.z), + }; + let ring = side.ring; + let corners = [0, 1].map(|cap| [side.index, side.next].map(|at| ring.vertices[cap][at])); + let uv = corners.iter().flatten().map(|vertex| { + let local = frame.local(builder.brep.topology.vertices[*vertex as usize].position); + [local[0], local[1]] + }); + let bounds = padded_uv_bounds(uv.collect::>(), builder.brep.accuracy.geometric); + let mut uses = Vec::with_capacity(4); + for (edge, from, to, sense) in side_steps(side) { + uses.push(plane_boundary(builder, frame, edge, from, to, sense)?); + } + add_face( + builder, + "half-space-side", + SurfaceGeometry::Plane { frame }, + bounds, + vec![uses], + provenance, + ) +} + +fn cylinder_pcurve(conic: &Conic, surface: u32) -> PcurveGeometry { + if conic.slope == 0.0 { + return uv_line([conic.alpha, conic.middle], [1.0, 0.0]); + } + let rise = conic.radius * conic.slope; + PcurveGeometry::ProjectedCurve { + curve: conic.curve, + surface, + chart: 0, + uv_hint: [conic.lo + conic.alpha, conic.middle + rise * conic.lo.cos()], + uv_rate: [1.0, -rise * conic.lo.sin()], + parameter_origin: conic.lo, + } +} + +fn add_cylinder_side( + builder: &mut Builder, + prism: &Prism, + side: &Side<'_>, + provenance: FaceProvenance, +) -> Result { + let ProfileEdge::Arc { + center, + radius, + start_angle, + sweep_angle, + } = side.span.edge + else { + return Err(gap()); + }; + let end_angle = start_angle + sweep_angle; + let surface = builder.brep.geometry.surfaces.len() as u32; + let on_cap = |cap: usize| { + side.ring.edges[cap][side.index] + .conic + .as_ref() + .map(|conic| cylinder_pcurve(conic, surface)) + .ok_or_else(gap) + }; + let (from, to) = side.span.edge.endpoints(); + let [low, high] = [prism.caps[0].height, prism.caps[1].height]; + let pcurves = [ + on_cap(0)?, + uv_line([end_angle, low.at(to)], [0.0, 1.0]), + on_cap(1)?, + uv_line([start_angle, low.at(from)], [0.0, 1.0]), + ]; + let uses = side_steps(side) + .into_iter() + .zip(pcurves) + .map(|((edge, from, to, sense), pcurve)| boundary(edge, from, to, sense, pcurve)) + .collect(); + let g = builder.brep.accuracy.geometric; + let arc = [&side.span.edge]; + let bounds = [ + [ + start_angle.min(end_angle) - g / radius, + start_angle.max(end_angle) + g / radius, + ], + [extent(arc, low)[0] - g, extent(arc, high)[1] + g], + ]; + let face = add_face( + builder, + "half-space-side", + SurfaceGeometry::Cylinder { + frame: Frame3 { + origin: prism.frame.point([center[0], center[1], 0.0]), + ..prism.frame + }, + radius, + }, + bounds, + vec![uses], + provenance, + )?; + if sweep_angle < 0.0 { + builder.brep.topology.faces[face as usize].sense = Orientation::Reverse; + } + Ok(face) +} + +pub(super) fn add_cap( + builder: &mut Builder, + prism: &Prism, + rings: &[Ring], + cap: usize, +) -> Result { + let Cap { + point, + normal, + height, + .. + } = &prism.caps[cap]; + let x = unit(sub( + prism.frame.x, + scale(*normal, dot(prism.frame.x, *normal)), + ))?; + let frame = Frame3 { + origin: *point, + x, + y: cross(*normal, x), + z: *normal, + }; + let loops = cap_loops(builder, frame, rings, cap, cap == 1)?; + let local = |axis: Point3| Affine { + k: dot(axis, sub(prism.frame.origin, *point)) + dot(axis, prism.frame.z) * height.k, + g: [ + dot(axis, prism.frame.x) + dot(axis, prism.frame.z) * height.g[0], + dot(axis, prism.frame.y) + dot(axis, prism.frame.z) * height.g[1], + ], + }; + let pad = builder.brep.accuracy.geometric; + let bounds = [frame.x, frame.y].map(|axis| { + let [lo, hi] = extent(prism.loops[0].iter().map(|span| &span.edge), local(axis)); + [lo - pad, hi + pad] + }); + let cap_ref = &prism.caps[cap]; + add_face( + builder, + if cap_ref.role == FaceRole::Cut { + "half-space-cut" + } else { + "half-space-cap" + }, + SurfaceGeometry::Plane { frame }, + bounds, + loops, + face_provenance(vec![cap_ref.source.clone()], cap_ref.role, cap_ref.reversed), + ) +} + +pub(super) fn cap_loops( + builder: &Builder, + frame: Frame3, + rings: &[Ring], + cap: usize, + along: bool, +) -> Result>, GeometryError> { + let mut loops = Vec::with_capacity(rings.len()); + for ring in rings { + let count = ring.vertices[cap].len(); + let mut uses = Vec::with_capacity(count); + for index in 0..count { + let next = (index + 1) % count; + let edge = &ring.edges[cap][index]; + let vertices = &ring.vertices[cap]; + uses.push(if along { + plane_boundary( + builder, + frame, + edge.edge, + vertices[index], + vertices[next], + sense(edge.forward), + )? + } else { + plane_boundary( + builder, + frame, + edge.edge, + vertices[next], + vertices[index], + sense(!edge.forward), + )? + }); + } + if !along { + uses.reverse(); + } + loops.push(uses); + } + Ok(loops) +} diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/cut.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/cut.rs new file mode 100644 index 00000000..cab9e792 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/cut.rs @@ -0,0 +1,137 @@ +use super::prism::{cap_height, gap, outward, Affine, Cap, Prism}; +use crate::brep::{BrepEnvelope, FaceRole, GeometryError, SurfaceGeometry}; +use crate::math::{dot, scale, sub, Point3}; +use crate::operations::modifying::boolean::operands::brep_face_source; +use crate::primitives::ProfileEdge; +use std::f64::consts::TAU; + +pub(super) struct Plane { + pub(super) point: Point3, + pub(super) outward: Point3, + pub(super) face: u32, +} + +pub(super) fn extent<'a>(edges: impl IntoIterator, f: Affine) -> [f64; 2] { + let mut range = [f64::INFINITY, f64::NEG_INFINITY]; + let mut add = |value: f64| { + range[0] = range[0].min(value); + range[1] = range[1].max(value); + }; + for edge in edges { + let (from, to) = edge.endpoints(); + add(f.at(from)); + add(f.at(to)); + if let ProfileEdge::Arc { + center, + radius, + start_angle, + sweep_angle, + } = *edge + { + let steepest = f.g[1].atan2(f.g[0]); + for turn in [steepest, steepest + 0.5 * TAU] { + let offset = if sweep_angle > 0.0 { + (turn - start_angle).rem_euclid(TAU) + } else { + (start_angle - turn).rem_euclid(TAU) + }; + if offset < sweep_angle.abs() { + add(f.at([ + center[0] + radius * turn.cos(), + center[1] + radius * turn.sin(), + ])); + } + } + } + } + range +} + +pub(super) fn cut_cap( + prism: &Prism, + cutter: &BrepEnvelope, + clearance: f64, +) -> Result<(usize, Cap), GeometryError> { + let planes = convex_planes(cutter, clearance)?; + for (index, plane) in planes.iter().enumerate() { + let normal = scale(plane.outward, -1.0); + let along = dot(normal, prism.frame.z); + if along.abs() < 1.0e-6 { + continue; + } + let height = cap_height(prism.frame, plane.point, normal); + let [low, high] = &prism.caps; + if extent(profile(prism), height.minus(low.height))[0] <= clearance + || extent(profile(prism), high.height.minus(height))[0] <= clearance + { + continue; + } + let replaced = usize::from(along > 0.0); + let removed = [height, prism.caps[replaced].height]; + let contained = planes.iter().enumerate().all(|(other, face)| { + other == index + || removed.iter().all(|cap| { + extent(profile(prism), plane_at_cap(prism, face, *cap))[1] < -clearance + }) + }); + if contained { + let cap = Cap { + point: plane.point, + normal, + height, + source: brep_face_source(cutter, plane.face), + role: FaceRole::Cut, + reversed: true, + }; + return Ok((replaced, cap)); + } + } + Err(gap()) +} + +pub(super) fn profile(prism: &Prism) -> impl Iterator { + prism.loops.iter().flatten().map(|span| &span.edge) +} + +pub(super) fn plane_at_cap(prism: &Prism, plane: &Plane, cap: Affine) -> Affine { + let frame = prism.frame; + let rise = dot(plane.outward, frame.z); + Affine { + k: dot(plane.outward, sub(frame.origin, plane.point)) + rise * cap.k, + g: [ + dot(plane.outward, frame.x) + rise * cap.g[0], + dot(plane.outward, frame.y) + rise * cap.g[1], + ], + } +} + +pub(super) fn convex_planes( + cutter: &BrepEnvelope, + clearance: f64, +) -> Result, GeometryError> { + if cutter.solids.len() != 1 || cutter.topology.shells.len() != 1 { + return Err(gap()); + } + let mut planes = Vec::with_capacity(cutter.topology.faces.len()); + for face in &cutter.topology.faces { + let SurfaceGeometry::Plane { frame } = &cutter.geometry.surfaces[face.surface as usize] + else { + return Err(gap()); + }; + planes.push(Plane { + point: frame.origin, + outward: outward(frame.z, face.sense), + face: face.id, + }); + } + let convex = cutter.topology.vertices.iter().all(|vertex| { + planes + .iter() + .all(|plane| dot(plane.outward, sub(vertex.position, plane.point)) <= clearance) + }); + if convex { + Ok(planes) + } else { + Err(gap()) + } +} diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/mod.rs new file mode 100644 index 00000000..c07cd0d1 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/mod.rs @@ -0,0 +1,70 @@ +mod build; +mod cut; +mod notch; +mod prism; + +use crate::brep::{Accuracy, BrepEnvelope, GeometryError, GeometryQuality}; +use crate::operations::modifying::boolean::assembly::analytic_face_mappings; +use crate::operations::modifying::boolean::types::{BooleanOp, BooleanReport, BooleanResult}; +use build::build; +use cut::cut_cap; +use notch::notch; +use prism::prism; + +pub(crate) fn subtract_half_space_from_arc_extrusion( + a: &BrepEnvelope, + b: &BrepEnvelope, + id: String, +) -> Result { + subtract_half_spaces_from_arc_extrusion(a, &[b], id) +} + +pub(crate) fn subtract_half_spaces_from_arc_extrusion( + host: &BrepEnvelope, + cutters: &[&BrepEnvelope], + id: String, +) -> Result { + let accuracy = cutters.iter().fold(host.accuracy, |accuracy, cutter| { + Accuracy::combined(accuracy, cutter.accuracy) + }); + let mut prism = prism(host)?; + for cutter in cutters { + let (replaced, cap) = cut_cap(&prism, cutter, 4.0 * accuracy.geometric)?; + prism.caps[replaced] = cap; + } + finish(build(&prism, id, accuracy)?, host, cutters) +} + +pub(crate) fn subtract_arc_extrusion_from_planar_solid( + host: &BrepEnvelope, + tool: &BrepEnvelope, + id: String, +) -> Result { + let accuracy = Accuracy::combined(host.accuracy, tool.accuracy); + finish(notch(host, tool, id, accuracy)?, host, &[tool]) +} + +fn finish( + mut out: BrepEnvelope, + host: &BrepEnvelope, + tools: &[&BrepEnvelope], +) -> Result { + out.revision = tools + .iter() + .fold(host.revision, |revision, tool| revision.max(tool.revision)) + .checked_add(1) + .ok_or_else(|| GeometryError::LimitExceeded("boolean revision overflow".into()))?; + out.validate()?; + let face_mappings = + analytic_face_mappings(&out, std::iter::once(host).chain(tools.iter().copied())); + Ok(BooleanResult { + report: BooleanReport { + operation: BooleanOp::Subtraction, + quality: GeometryQuality::Analytic, + contacts: Vec::new(), + coincident: false, + face_mappings, + }, + brep: out, + }) +} diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/notch.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/notch.rs new file mode 100644 index 00000000..e6490c8f --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/notch.rs @@ -0,0 +1,201 @@ +use super::build::{add_cap, add_face, add_rings, add_sides, cap_loops, Ring}; +use super::cut::{convex_planes, extent, plane_at_cap, profile, Plane}; +use super::prism::{cap_height, face_loops, gap, loop_halfedges, prism, Cap, Prism}; +use crate::brep::{ + padded_uv_bounds, plane_boundary, Accuracy, BrepEnvelope, Builder, EdgeGeometry, FaceRole, + Frame3, GeometryError, Orientation, SurfaceGeometry, +}; +use crate::math::{dot, scale}; +use crate::operations::modifying::boolean::assembly::{face_provenance, reverse_face}; +use crate::operations::modifying::boolean::operands::brep_face_source; + +struct HostCopy { + vertices: Vec, + edges: Vec, + holes: [Option; 2], +} + +pub(super) fn notch( + host: &BrepEnvelope, + tool: &BrepEnvelope, + id: String, + accuracy: Accuracy, +) -> Result { + let clearance = 4.0 * accuracy.geometric; + let planes = convex_planes(host, clearance)?; + let mut prism = prism(tool)?; + if prism.loops.len() != 1 { + return Err(gap()); + } + let entries = clip(&mut prism, host, &planes, clearance)?; + for (cap, entry) in prism.caps.iter_mut().zip(entries) { + if entry.is_none() { + cap.role = FaceRole::Cut; + cap.reversed = true; + } + } + let mut builder = Builder::new(id, accuracy)?; + let rings = add_rings(&mut builder, &prism)?; + let mut reversed = add_sides(&mut builder, &prism, &rings, FaceRole::Cut, true)?; + for (cap, entry) in entries.iter().enumerate() { + if entry.is_none() { + reversed.push(add_cap(&mut builder, &prism, &rings, cap)?); + } + } + let copy = host_copy( + &mut builder, + host, + entries.map(|e| e.map(|i| planes[i].face)), + )?; + for face in &host.topology.faces { + add_host_face(&mut builder, host, ©, &rings, face.id)?; + } + for face in reversed { + reverse_face(&mut builder.brep, face); + } + builder.finish_solid() +} + +fn crossing(prism: &Prism, plane: &Plane, clearance: f64) -> Option { + let along = dot(plane.outward, prism.frame.z); + if along.abs() < 1.0e-6 { + return None; + } + let height = cap_height(prism.frame, plane.point, plane.outward); + let [low, high] = &prism.caps; + let between = extent(profile(prism), height.minus(low.height))[0] > clearance + && extent(profile(prism), high.height.minus(height))[0] > clearance; + between.then_some(usize::from(along > 0.0)) +} + +fn inside(prism: &Prism, plane: &Plane, clearance: f64) -> bool { + prism + .caps + .iter() + .all(|cap| extent(profile(prism), plane_at_cap(prism, plane, cap.height))[1] < -clearance) +} + +fn clip( + prism: &mut Prism, + host: &BrepEnvelope, + planes: &[Plane], + clearance: f64, +) -> Result<[Option; 2], GeometryError> { + let mut entries = [None, None]; + let mut changed = true; + while changed { + changed = false; + for (index, plane) in planes.iter().enumerate() { + if entries.contains(&Some(index)) || inside(prism, plane, clearance) { + continue; + } + if let Some(cap) = crossing(prism, plane, clearance) { + prism.caps[cap] = Cap { + point: plane.point, + normal: plane.outward, + height: cap_height(prism.frame, plane.point, plane.outward), + source: brep_face_source(host, plane.face), + role: FaceRole::Split, + reversed: false, + }; + entries[cap] = Some(index); + changed = true; + } + } + } + let settled = planes + .iter() + .enumerate() + .all(|(index, plane)| entries.contains(&Some(index)) || inside(prism, plane, clearance)); + let oblique = entries + .iter() + .flatten() + .any(|&index| 1.0 - dot(planes[index].outward, prism.frame.z).abs() > 1.0e-9); + if settled && oblique { + Ok(entries) + } else { + Err(gap()) + } +} + +fn host_copy( + builder: &mut Builder, + host: &BrepEnvelope, + holes: [Option; 2], +) -> Result { + let vertices = host + .topology + .vertices + .iter() + .map(|vertex| builder.vertex(vertex.position)) + .collect(); + let mut edges = Vec::with_capacity(host.topology.edges.len()); + for edge in &host.topology.edges { + let EdgeGeometry::Curve { curve, range } = &edge.geometry else { + return Err(gap()); + }; + edges.push(builder.edge(host.geometry.curves[*curve as usize].clone(), *range, false)); + } + Ok(HostCopy { + vertices, + edges, + holes, + }) +} + +fn add_host_face( + builder: &mut Builder, + host: &BrepEnvelope, + copy: &HostCopy, + rings: &[Ring], + face: u32, +) -> Result<(), GeometryError> { + let source = &host.topology.faces[face as usize]; + let plane = *host.geometry.surfaces[source.surface as usize].frame(); + let frame = if source.sense == Orientation::Reverse { + Frame3 { + y: scale(plane.y, -1.0), + z: scale(plane.z, -1.0), + ..plane + } + } else { + plane + }; + let mut loops = Vec::new(); + let mut corners = Vec::new(); + for start in face_loops(host, face) { + let mut uses = Vec::new(); + for halfedge in loop_halfedges(host, start)? { + let from = copy.vertices[halfedge.from as usize]; + let local = frame.local(host.topology.vertices[halfedge.from as usize].position); + corners.push([local[0], local[1]]); + uses.push(plane_boundary( + builder, + frame, + copy.edges[halfedge.edge as usize], + from, + copy.vertices[halfedge.to as usize], + halfedge.geometry_use.sense, + )?); + } + loops.push(uses); + } + let holed = copy.holes.iter().position(|hole| *hole == Some(face)); + if let Some(cap) = holed { + loops.extend(cap_loops(builder, frame, rings, cap, cap == 0)?); + } + let role = if holed.is_some() { + FaceRole::Split + } else { + FaceRole::Preserved + }; + add_face( + builder, + "notch-host", + SurfaceGeometry::Plane { frame }, + padded_uv_bounds(corners, builder.brep.accuracy.geometric), + loops, + face_provenance(vec![brep_face_source(host, face)], role, false), + )?; + Ok(()) +} diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/prism.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/prism.rs new file mode 100644 index 00000000..ff9dff28 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/prism.rs @@ -0,0 +1,355 @@ +use crate::brep::{ + unit, BrepEnvelope, CurveGeometry, EdgeGeometry, FaceRole, FaceSource, Frame3, GeometryError, + HalfEdge, Orientation, SurfaceGeometry, +}; +use crate::math::{cross, dot, scale, sub, Point3}; +use crate::operations::modifying::boolean::operands::brep_face_source; +use crate::primitives::ProfileEdge; +use std::f64::consts::{PI, TAU}; + +#[derive(Clone, Copy)] +pub(super) struct Affine { + pub(super) k: f64, + pub(super) g: [f64; 2], +} + +impl Affine { + pub(super) fn at(self, q: [f64; 2]) -> f64 { + self.k + self.g[0] * q[0] + self.g[1] * q[1] + } + + pub(super) fn minus(self, other: Self) -> Self { + Self { + k: self.k - other.k, + g: [self.g[0] - other.g[0], self.g[1] - other.g[1]], + } + } +} + +#[derive(Clone)] +pub(super) struct Cap { + pub(super) point: Point3, + pub(super) normal: Point3, + pub(super) height: Affine, + pub(super) source: FaceSource, + pub(super) role: FaceRole, + pub(super) reversed: bool, +} + +pub(super) struct Span { + pub(super) edge: ProfileEdge, + pub(super) source: FaceSource, +} + +pub(super) struct Prism { + pub(super) frame: Frame3, + pub(super) loops: Vec>, + pub(super) caps: [Cap; 2], +} + +struct ArcSource { + center: [f64; 2], + radius: f64, + counter_clockwise: bool, +} + +pub(super) fn gap() -> GeometryError { + GeometryError::CoverageGap { + families: ["arc-edged extrusion".into(), "half-space cutter".into()], + } +} + +pub(super) fn outward(normal: Point3, sense: Orientation) -> Point3 { + if sense == Orientation::Reverse { + scale(normal, -1.0) + } else { + normal + } +} + +pub(super) fn cap_height(frame: Frame3, point: Point3, normal: Point3) -> Affine { + let along = dot(normal, frame.z); + Affine { + k: dot(normal, sub(point, frame.origin)) / along, + g: [-dot(normal, frame.x) / along, -dot(normal, frame.y) / along], + } +} + +pub(super) fn prism(host: &BrepEnvelope) -> Result { + if host.solids.len() != 1 + || host.topology.shells.len() != 1 + || !host.solids[0].cavity_shells.is_empty() + { + return Err(gap()); + } + let frame = section_frame(host)?; + let [low, high] = cap_faces(host, frame)?; + let loops = cap_loops(host, frame, high)?; + check_low_cap(host, frame, low, &loops)?; + let caps = [low, high].map(|face| { + let face_ref = &host.topology.faces[face as usize]; + let plane = host.geometry.surfaces[face_ref.surface as usize].frame(); + let normal = outward(plane.z, face_ref.sense); + Cap { + point: plane.origin, + normal, + height: cap_height(frame, plane.origin, normal), + source: brep_face_source(host, face), + role: FaceRole::Preserved, + reversed: false, + } + }); + Ok(Prism { frame, loops, caps }) +} + +fn section_frame(host: &BrepEnvelope) -> Result { + let axis = host + .geometry + .surfaces + .iter() + .find_map(|surface| match surface { + SurfaceGeometry::Cylinder { frame, .. } => Some(*frame), + _ => None, + }) + .ok_or_else(gap)?; + let x = unit(sub(axis.x, scale(axis.z, dot(axis.x, axis.z))))?; + Ok(Frame3 { + origin: axis.origin, + x, + y: cross(axis.z, x), + z: axis.z, + }) +} + +fn cap_faces(host: &BrepEnvelope, frame: Frame3) -> Result<[u32; 2], GeometryError> { + let mut caps = [None, None]; + for face in &host.topology.faces { + let along = match &host.geometry.surfaces[face.surface as usize] { + SurfaceGeometry::Plane { frame: plane } => dot(outward(plane.z, face.sense), frame.z), + SurfaceGeometry::Cylinder { frame: axis, .. } + if 1.0 - dot(axis.z, frame.z).abs() <= 1.0e-12 => + { + 0.0 + } + _ => return Err(gap()), + }; + if along.abs() <= 1.0e-12 { + continue; + } + if along.abs() < 1.0e-6 { + return Err(gap()); + } + let slot = &mut caps[usize::from(along > 0.0)]; + if slot.replace(face.id).is_some() { + return Err(gap()); + } + } + match caps { + [Some(low), Some(high)] => Ok([low, high]), + _ => Err(gap()), + } +} + +pub(super) fn loop_halfedges( + host: &BrepEnvelope, + start: u32, +) -> Result, GeometryError> { + let mut halfedges = Vec::new(); + let mut current = start; + loop { + let halfedge = &host.topology.halfedges[current as usize]; + halfedges.push(halfedge); + current = halfedge.next.ok_or_else(gap)?; + if current == start { + return Ok(halfedges); + } + if halfedges.len() > host.topology.halfedges.len() { + return Err(gap()); + } + } +} + +pub(super) fn face_loops(host: &BrepEnvelope, face: u32) -> Vec { + let trim = &host.topology.faces[face as usize].trim; + std::iter::once(trim.outer) + .chain(trim.holes.iter().copied()) + .map(|id| host.topology.loops[id as usize].start_halfedge) + .collect() +} + +fn section(frame: Frame3, point: Point3) -> [f64; 2] { + let local = frame.local(point); + [local[0], local[1]] +} + +fn cap_loops( + host: &BrepEnvelope, + frame: Frame3, + face: u32, +) -> Result>, GeometryError> { + let mut loops = Vec::new(); + for (index, start) in face_loops(host, face).into_iter().enumerate() { + let mut spans = Vec::new(); + for halfedge in loop_halfedges(host, start)? { + let twin = halfedge.twin.ok_or_else(gap)?; + let side = host.topology.halfedges[twin as usize] + .face + .ok_or_else(gap)?; + for edge in span_edges(host, frame, halfedge)? { + spans.push(Span { + edge, + source: brep_face_source(host, side), + }); + } + } + let area = area_twice(&spans); + if (index == 0) != (area > 0.0) { + return Err(gap()); + } + loops.push(spans); + } + Ok(loops) +} + +fn span_edges( + host: &BrepEnvelope, + frame: Frame3, + halfedge: &HalfEdge, +) -> Result, GeometryError> { + let EdgeGeometry::Curve { curve, range } = + &host.topology.edges[halfedge.edge as usize].geometry + else { + return Err(gap()); + }; + let geometry = &host.geometry.curves[*curve as usize]; + let position = |vertex: u32| host.topology.vertices[vertex as usize].position; + let from = section(frame, position(halfedge.from)); + let to = section(frame, position(halfedge.to)); + let (center, radius) = match geometry { + CurveGeometry::Line { .. } => return Ok(vec![ProfileEdge::Line { from, to }]), + CurveGeometry::Circle { frame: c, radius } => (c.origin, *radius), + CurveGeometry::Ellipse { + frame: c, + minor_radius, + .. + } => (c.origin, *minor_radius), + _ => return Err(gap()), + }; + let center = section(frame, center); + let middle = 0.5 * (range.lo + range.hi); + let radial = sub2(section(frame, geometry.elementary_point(middle)?), center); + let tangent = geometry.elementary_tangent(middle)?; + let turn = radial[0] * dot(tangent, frame.y) - radial[1] * dot(tangent, frame.x); + let arc = ArcSource { + center, + radius, + counter_clockwise: (turn > 0.0) == (halfedge.geometry_use.sense == Orientation::Forward), + }; + arc_edges(&arc, from, to, halfedge.from == halfedge.to) +} + +fn sub2(a: [f64; 2], b: [f64; 2]) -> [f64; 2] { + [a[0] - b[0], a[1] - b[1]] +} + +fn angle(center: [f64; 2], point: [f64; 2]) -> f64 { + let d = sub2(point, center); + d[1].atan2(d[0]) +} + +fn arc_edges( + arc: &ArcSource, + from: [f64; 2], + to: [f64; 2], + closed: bool, +) -> Result, GeometryError> { + let start = angle(arc.center, from); + let sign = if arc.counter_clockwise { 1.0 } else { -1.0 }; + let edge = |start_angle: f64, sweep_angle: f64| ProfileEdge::Arc { + center: arc.center, + radius: arc.radius, + start_angle, + sweep_angle, + }; + if closed { + return Ok(vec![ + edge(start, sign * PI), + edge(start + sign * PI, sign * PI), + ]); + } + let sweep = sign * (sign * (angle(arc.center, to) - start)).rem_euclid(TAU); + if sweep.abs() <= 1.0e-9 { + return Err(gap()); + } + Ok(vec![edge(start, sweep)]) +} + +fn area_twice(spans: &[Span]) -> f64 { + spans + .iter() + .map(|span| { + let (p, q) = span.edge.endpoints(); + let chord = p[0] * q[1] - q[0] * p[1]; + match span.edge { + ProfileEdge::Line { .. } => chord, + ProfileEdge::Arc { + center, + radius, + sweep_angle, + .. + } => { + let fan = center[0] * (q[1] - p[1]) - center[1] * (q[0] - p[0]); + fan + radius * radius * sweep_angle + } + } + }) + .sum() +} + +fn check_low_cap( + host: &BrepEnvelope, + frame: Frame3, + face: u32, + loops: &[Vec], +) -> Result<(), GeometryError> { + let tolerance = 4.0 * host.accuracy.geometric; + let points = loops + .iter() + .flatten() + .map(|span| span.edge.endpoints().0) + .collect::>(); + let mut sides = loops + .iter() + .flatten() + .map(|span| span.source.face) + .collect::>(); + let mut low_sides = Vec::new(); + for start in face_loops(host, face) { + for halfedge in loop_halfedges(host, start)? { + let twin = halfedge.twin.ok_or_else(gap)?; + low_sides.push( + host.topology.halfedges[twin as usize] + .face + .ok_or_else(gap)?, + ); + let point = section( + frame, + host.topology.vertices[halfedge.from as usize].position, + ); + if !points.iter().any(|known| { + let d = sub2(point, *known); + d[0].hypot(d[1]) <= tolerance + }) { + return Err(gap()); + } + } + } + sides.sort_unstable(); + sides.dedup(); + low_sides.sort_unstable(); + low_sides.dedup(); + if sides != low_sides || sides.len() + 2 != host.topology.faces.len() { + return Err(gap()); + } + Ok(()) +} diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/mod.rs index cc510a7a..d00ca4d8 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/mod.rs @@ -3,6 +3,7 @@ mod band_chain_faces; mod band_chain_seam; mod band_notch; mod band_rim; +mod half_space_extrusion; mod multiple_closed_loops; mod non_intersecting; mod prismatic_curved; @@ -11,6 +12,10 @@ mod single_closed_loop; mod tests; mod two_sided_cutter_band; +pub(crate) use half_space_extrusion::{ + subtract_arc_extrusion_from_planar_solid, subtract_half_space_from_arc_extrusion, + subtract_half_spaces_from_arc_extrusion, +}; pub(crate) use multiple_closed_loops::generic_multiple_closed_loops_boolean; pub(crate) use non_intersecting::generic_non_intersecting_boolean; pub(crate) use prismatic_curved::prismatic_curved_boolean; diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs index fa669a2d..d2a90217 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs @@ -27,7 +27,8 @@ pub(super) use disjoint::disjoint_boolean; pub(super) use generic::{ generic_multiple_closed_loops_boolean, generic_non_intersecting_boolean, generic_single_closed_loop_boolean, generic_two_sided_cutter_band_subtraction, - prismatic_curved_boolean, + prismatic_curved_boolean, subtract_arc_extrusion_from_planar_solid, + subtract_half_space_from_arc_extrusion, subtract_half_spaces_from_arc_extrusion, }; pub(super) use planar::{boolean_planar_polyhedra, subtract_layered_extrusions}; pub(super) use planar_extrusions::boolean_planar_extrusions; diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_half_space.rs b/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_half_space.rs new file mode 100644 index 00000000..b8fa6fb3 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_half_space.rs @@ -0,0 +1,404 @@ +use super::support::volume_at_deflection; +use crate::brep::{ + fine_accuracy, BrepEnvelope, CurveGeometry, EdgeGeometry, FaceRole, Frame3, SurfaceGeometry, +}; +use crate::math::{dot, Point3}; +use crate::operations::modifying::boolean::batch::subtract_planar_cutters_with_handlers; +use crate::operations::modifying::boolean::dispatch::boolean_brep_with_handlers; +use crate::operations::modifying::boolean::types::BooleanOp; +use crate::primitives::{self, ProfileEdge}; +use crate::query::{classify_point, PointClassification}; +use std::f64::consts::PI; + +pub(super) const HANDLER: &str = "subtract_half_space_from_arc_extrusion"; + +pub(super) fn along_x(origin: Point3) -> Frame3 { + Frame3::from_axis(origin, [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]).unwrap() +} + +fn along_y(origin: Point3) -> Frame3 { + Frame3::from_axis(origin, [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]).unwrap() +} + +pub(super) fn half_round_edges() -> Vec { + vec![ + ProfileEdge::Arc { + center: [0.0, 0.0], + radius: 0.1, + start_angle: PI, + sweep_angle: PI, + }, + ProfileEdge::Line { + from: [0.1, 0.0], + to: [0.09, 0.0], + }, + ProfileEdge::Arc { + center: [0.0, 0.0], + radius: 0.09, + start_angle: 0.0, + sweep_angle: -PI, + }, + ProfileEdge::Line { + from: [-0.09, 0.0], + to: [-0.1, 0.0], + }, + ] +} + +fn full_round_edges() -> Vec { + vec![ + ProfileEdge::Arc { + center: [0.0, 0.0], + radius: 0.1, + start_angle: 0.0, + sweep_angle: PI, + }, + ProfileEdge::Arc { + center: [0.0, 0.0], + radius: 0.1, + start_angle: PI, + sweep_angle: PI, + }, + ] +} + +fn ogee_edges() -> Vec { + vec![ + ProfileEdge::Line { + from: [-0.1, 0.0], + to: [0.0, 0.0], + }, + ProfileEdge::Arc { + center: [0.0, 0.05], + radius: 0.05, + start_angle: -PI / 2.0, + sweep_angle: PI / 2.0, + }, + ProfileEdge::Arc { + center: [0.1, 0.05], + radius: 0.05, + start_angle: PI, + sweep_angle: -PI / 2.0, + }, + ProfileEdge::Line { + from: [0.1, 0.1], + to: [-0.1, 0.1], + }, + ProfileEdge::Line { + from: [-0.1, 0.1], + to: [-0.1, 0.0], + }, + ] +} + +pub(super) fn extrusion(frame: Frame3, edges: Vec, length: f64) -> BrepEnvelope { + primitives::arc_edged_extrusion("gutter".into(), frame, edges, length, fine_accuracy()).unwrap() +} + +pub(super) fn prism_cutter(id: &str, ring: Vec<[f64; 2]>) -> BrepEnvelope { + primitives::linear_extrusion( + id.into(), + Frame3 { + origin: [0.0, 0.0, -1.0], + ..Frame3::IDENTITY + }, + ring, + vec![], + 2.0, + fine_accuracy(), + ) + .unwrap() +} + +pub(super) fn slanted_prism(degrees: f64) -> BrepEnvelope { + let t = degrees.to_radians(); + let along = [-t.sin(), t.cos()]; + let at = |s: f64, away: f64| [3.0 + s * along[0] + away, s * along[1]]; + prism_cutter( + "slanted-prism", + vec![at(-2.0, 0.0), at(2.0, 0.0), at(2.0, 2.0), at(-2.0, 2.0)], + ) +} + +pub(super) fn slanted_cuboid(degrees: f64) -> BrepEnvelope { + slanted_block(degrees, 10.0) +} + +pub(super) fn slanted_block(degrees: f64, depth: f64) -> BrepEnvelope { + let t = degrees.to_radians(); + let x = [t.cos(), t.sin(), 0.0]; + let y = [-t.sin(), t.cos(), 0.0]; + primitives::cuboid( + "slanted-cuboid".into(), + Frame3 { + origin: [3.0 - 5.0 * y[0], -5.0 * y[1], -1.0], + x, + y, + z: [0.0, 0.0, 1.0], + }, + [depth, 10.0, 2.0], + fine_accuracy(), + ) + .unwrap() +} + +pub(super) fn cut_ellipses(brep: &BrepEnvelope) -> Vec<(Point3, f64, f64)> { + brep.topology + .edges + .iter() + .filter_map(|edge| match &edge.geometry { + EdgeGeometry::Curve { curve, .. } => match &brep.geometry.curves[*curve as usize] { + CurveGeometry::Ellipse { + frame, + major_radius, + minor_radius, + } => Some((frame.origin, *major_radius, *minor_radius)), + _ => None, + }, + _ => None, + }) + .collect() +} + +fn assert_exact_cut(brep: &BrepEnvelope, radii: &[f64], axis_to_normal: f64) { + brep.validate().unwrap(); + assert_eq!(brep.solids.len(), 1); + assert!(brep.topology.shells.iter().all(|shell| shell.is_closed)); + assert!(brep.geometry.surfaces.iter().all(|surface| matches!( + surface, + SurfaceGeometry::Plane { .. } | SurfaceGeometry::Cylinder { .. } + ))); + let cylinders = brep + .topology + .faces + .iter() + .filter(|face| { + matches!( + brep.geometry.surfaces[face.surface as usize], + SurfaceGeometry::Cylinder { .. } + ) + }) + .count(); + assert!(cylinders >= radii.len(), "{cylinders} cylinder faces"); + let ellipses = cut_ellipses(brep); + for radius in radii { + assert!( + ellipses + .iter() + .any(|(_, major, minor)| (minor - radius).abs() <= 1e-12 + && (major - radius / axis_to_normal.cos()).abs() <= 1e-12), + "no ellipse of minor radius {radius} in {ellipses:?}" + ); + } +} + +fn kept_volume(host: &BrepEnvelope, cutter: &BrepEnvelope) -> f64 { + let (result, handlers) = + boolean_brep_with_handlers(host, cutter, BooleanOp::Subtraction, "cut".into()) + .unwrap_or_else(|error| panic!("{error:?}")); + assert_eq!(handlers, [HANDLER]); + volume_at_deflection(&result.brep, 1e-4).abs() +} + +#[test] +fn half_round_gutter_cut_by_a_slanted_plane_keeps_its_cylinders_and_gets_ellipse_trims() { + let host = extrusion(along_x([0.0; 3]), half_round_edges(), 4.0); + let area = PI * (0.1f64.powi(2) - 0.09f64.powi(2)) / 2.0; + for degrees in [30.0, 45.0, 60.0] { + for cutter in [slanted_prism(degrees), slanted_cuboid(degrees)] { + let (result, handlers) = + boolean_brep_with_handlers(&host, &cutter, BooleanOp::Subtraction, "cut".into()) + .unwrap_or_else(|error| panic!("{degrees}: {error:?}")); + assert_eq!(handlers, [HANDLER]); + assert_exact_cut(&result.brep, &[0.1, 0.09], f64::to_radians(degrees)); + let volume = volume_at_deflection(&result.brep, 1e-4).abs(); + assert!((volume - 3.0 * area).abs() <= 1e-3 * 3.0 * area, "{volume}"); + assert_eq!( + classify_point(&result.brep, [2.9, 0.0, -0.095]).unwrap(), + PointClassification::Inside + ); + assert_eq!( + classify_point(&result.brep, [3.1, 0.0, -0.095]).unwrap(), + PointClassification::Outside + ); + } + } +} + +#[test] +fn full_round_extrusion_cut_by_a_slanted_plane_keeps_its_cylinders_and_gets_an_ellipse_trim() { + let host = extrusion(along_x([0.0; 3]), full_round_edges(), 4.0); + let area = PI * 0.01; + for degrees in [30.0, 45.0, 60.0] { + for cutter in [slanted_prism(degrees), slanted_cuboid(degrees)] { + let (result, handlers) = + boolean_brep_with_handlers(&host, &cutter, BooleanOp::Subtraction, "cut".into()) + .unwrap_or_else(|error| panic!("{degrees}: {error:?}")); + assert_eq!(handlers, [HANDLER]); + assert_exact_cut(&result.brep, &[0.1], f64::to_radians(degrees)); + let volume = volume_at_deflection(&result.brep, 1e-4).abs(); + assert!((volume - 3.0 * area).abs() <= 1e-3 * 3.0 * area, "{volume}"); + } + } +} + +#[test] +fn primitive_cylinder_cut_by_a_slanted_prism_gets_an_ellipse_trim() { + let host = + primitives::cylinder("rod".into(), along_x([0.0; 3]), 0.1, 4.0, fine_accuracy()).unwrap(); + for degrees in [30.0, 45.0, 60.0] { + let (result, handlers) = boolean_brep_with_handlers( + &host, + &slanted_prism(degrees), + BooleanOp::Subtraction, + "cut".into(), + ) + .unwrap_or_else(|error| panic!("{degrees}: {error:?}")); + assert_eq!(handlers, [HANDLER]); + assert_exact_cut(&result.brep, &[0.1], f64::to_radians(degrees)); + let volume = volume_at_deflection(&result.brep, 1e-4).abs(); + assert!((volume - 0.03 * PI).abs() <= 3e-5 * PI, "{volume}"); + } +} + +#[test] +fn ogee_cut_at_opposite_slants_keeps_the_volume_a_plane_through_its_middle_gives() { + let host = extrusion(along_x([0.0; 3]), ogee_edges(), 4.0); + let whole = volume_at_deflection(&host, 1e-4).abs(); + for degrees in [30.0, 45.0, 60.0] { + let left = kept_volume(&host, &slanted_prism(degrees)); + let right = kept_volume(&host, &slanted_prism(-degrees)); + assert!( + (left + right - 1.5 * whole).abs() <= 1e-3 * whole, + "{left} {right}" + ); + } +} + +fn corner_gutters() -> [BrepEnvelope; 2] { + [ + extrusion(along_x([-1.0, -0.15, 0.0]), half_round_edges(), 4.0), + extrusion(along_y([-0.15, -1.0, 0.0]), half_round_edges(), 4.0), + ] +} + +#[test] +fn two_gutters_mitred_on_the_corner_bisector_share_one_ellipse_on_the_mitre() { + let [along_x_gutter, along_y_gutter] = corner_gutters(); + let x_cutter = prism_cutter( + "mitre-x", + vec![[-2.0, -2.0], [2.0, 2.0], [-2.0, 2.0], [-6.0, -2.0]], + ); + let y_cutter = prism_cutter( + "mitre-y", + vec![[-2.0, -2.0], [2.0, 2.0], [2.0, -2.0], [-2.0, -6.0]], + ); + let mut mitres = Vec::new(); + for (host, cutter) in [(&along_x_gutter, &x_cutter), (&along_y_gutter, &y_cutter)] { + let (result, handlers) = + boolean_brep_with_handlers(host, cutter, BooleanOp::Subtraction, "mitre".into()) + .unwrap_or_else(|error| panic!("{error:?}")); + assert_eq!(handlers, [HANDLER]); + assert_exact_cut(&result.brep, &[0.1, 0.09], PI / 4.0); + let area = PI * (0.1f64.powi(2) - 0.09f64.powi(2)) / 2.0; + let volume = volume_at_deflection(&result.brep, 1e-4).abs(); + assert!( + (volume - 3.15 * area).abs() <= 1e-3 * 3.15 * area, + "{volume}" + ); + mitres.push(cut_ellipses(&result.brep)); + } + for (center, major, minor) in &mitres[0] { + assert!((dot(*center, [1.0, -1.0, 0.0])).abs() <= 1e-12); + assert!(mitres[1].iter().any(|(other, other_major, other_minor)| { + (0..3).all(|axis| (center[axis] - other[axis]).abs() <= 1e-12) + && (major - other_major).abs() <= 1e-12 + && (minor - other_minor).abs() <= 1e-12 + })); + } +} + +#[test] +fn a_gutter_mitred_at_both_ends_in_one_batch_keeps_its_cylinders() { + let [host, _] = corner_gutters(); + let near = prism_cutter( + "mitre-near", + vec![[-2.0, -2.0], [2.0, 2.0], [-2.0, 2.0], [-6.0, -2.0]], + ); + let far = prism_cutter( + "mitre-far", + vec![[0.0, 2.0], [4.0, -2.0], [8.0, -2.0], [4.0, 2.0]], + ); + let (result, handlers) = + subtract_planar_cutters_with_handlers(&host, &[near, far], "mitres".into()) + .unwrap_or_else(|error| panic!("{error:?}")); + assert_eq!(handlers, ["subtract_planar_cutters", HANDLER, HANDLER]); + assert_exact_cut(&result.brep, &[0.1, 0.09], PI / 4.0); + assert_eq!(cut_ellipses(&result.brep).len(), 4); + let area = PI * (0.1f64.powi(2) - 0.09f64.powi(2)) / 2.0; + let volume = volume_at_deflection(&result.brep, 1e-4).abs(); + assert!((volume - 2.3 * area).abs() <= 1e-3 * 2.3 * area, "{volume}"); +} + +fn cut_end_edges(brep: &BrepEnvelope) -> Vec<(CurveGeometry, [f64; 2])> { + let face = brep + .topology + .faces + .iter() + .find(|face| face.provenance.role == FaceRole::Cut) + .unwrap(); + let start = brep.topology.loops[face.trim.outer as usize].start_halfedge; + let mut halfedge = start; + let mut edges = Vec::new(); + loop { + let current = &brep.topology.halfedges[halfedge as usize]; + let EdgeGeometry::Curve { curve, range } = + &brep.topology.edges[current.edge as usize].geometry + else { + panic!("collapsed edge on the cut end"); + }; + edges.push(( + brep.geometry.curves[*curve as usize].clone(), + [range.lo, range.hi], + )); + halfedge = current.next.unwrap(); + if halfedge == start { + return edges; + } + } +} + +#[test] +fn the_mitre_end_is_bounded_by_exact_half_ellipses_on_the_bisector_and_the_gutter_cylinders() { + let [host, _] = corner_gutters(); + let cutter = prism_cutter( + "mitre-x", + vec![[-2.0, -2.0], [2.0, 2.0], [-2.0, 2.0], [-6.0, -2.0]], + ); + let (result, _) = + boolean_brep_with_handlers(&host, &cutter, BooleanOp::Subtraction, "mitre".into()).unwrap(); + let edges = cut_end_edges(&result.brep); + let mut spans = Vec::new(); + for (curve, [lo, hi]) in &edges { + let CurveGeometry::Ellipse { minor_radius, .. } = curve else { + assert!(matches!(curve, CurveGeometry::Line { .. }), "{curve:?}"); + continue; + }; + for step in 0..=8 { + let point = curve + .elementary_point(lo + (hi - lo) * f64::from(step) / 8.0) + .unwrap(); + assert!(dot(point, [1.0, -1.0, 0.0]).abs() <= 1e-12, "{point:?}"); + assert!(((point[1] + 0.15).hypot(point[2]) - minor_radius).abs() <= 1e-12); + assert!(point[2] <= 1e-12, "{point:?}"); + } + spans.push((*minor_radius, hi - lo)); + } + assert_eq!(edges.len(), 4); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + for ((radius, span), expected) in spans.iter().zip([0.09, 0.1]) { + assert!((radius - expected).abs() <= 1e-12); + assert!((span - PI).abs() <= 1e-12, "{span}"); + } + assert_eq!(spans.len(), 2); +} diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_notch.rs b/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_notch.rs new file mode 100644 index 00000000..4d7cced6 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_notch.rs @@ -0,0 +1,82 @@ +use super::arc_extrusions_half_space::{ + along_x, cut_ellipses, extrusion, half_round_edges, slanted_block, slanted_cuboid, + slanted_prism, HANDLER, +}; +use super::support::volume_at_deflection; +use crate::brep::{fine_accuracy, BrepEnvelope, SurfaceGeometry}; +use crate::operations::modifying::boolean::dispatch::boolean_brep_with_handlers; +use crate::operations::modifying::boolean::types::BooleanOp; +use crate::primitives; +use crate::query::{classify_point, PointClassification}; +use std::f64::consts::PI; + +fn rod() -> BrepEnvelope { + primitives::cylinder("rod".into(), along_x([0.0; 3]), 0.1, 4.0, fine_accuracy()).unwrap() +} + +fn assert_notch(host: &BrepEnvelope, tool: &BrepEnvelope, radii: &[f64], degrees: f64) -> f64 { + let (result, handlers) = + boolean_brep_with_handlers(host, tool, BooleanOp::Subtraction, "notch".into()) + .unwrap_or_else(|error| panic!("{degrees}: {error:?}")); + assert_eq!(handlers, [HANDLER]); + let brep = &result.brep; + brep.validate().unwrap(); + assert_eq!(brep.solids.len(), 1); + assert!(brep.topology.shells.iter().all(|shell| shell.is_closed)); + assert!(brep.geometry.surfaces.iter().all(|surface| matches!( + surface, + SurfaceGeometry::Plane { .. } | SurfaceGeometry::Cylinder { .. } + ))); + let ellipses = cut_ellipses(brep); + let slant = degrees.to_radians().cos(); + for radius in radii { + assert!( + ellipses + .iter() + .any(|(_, major, minor)| (minor - radius).abs() <= 1e-12 + && (major - radius / slant).abs() <= 1e-12), + "no rim ellipse of minor radius {radius} in {ellipses:?}" + ); + } + volume_at_deflection(host, 1e-5).abs() - volume_at_deflection(brep, 1e-5).abs() +} + +#[test] +fn slanted_cuboid_minus_a_rod_keeps_a_blind_round_hole_with_an_ellipse_rim() { + for degrees in [30.0, 45.0, 60.0] { + let cuboid = slanted_cuboid(degrees); + let removed = assert_notch(&cuboid, &rod(), &[0.1], degrees); + let expected = PI * 0.01; + assert!((removed - expected).abs() <= 2e-3 * expected, "{removed}"); + let (result, _) = + boolean_brep_with_handlers(&cuboid, &rod(), BooleanOp::Subtraction, "notch".into()) + .unwrap(); + assert_eq!( + classify_point(&result.brep, [3.5, 0.0, 0.0]).unwrap(), + PointClassification::Outside + ); + assert_eq!( + classify_point(&result.brep, [3.5, 0.5, 0.0]).unwrap(), + PointClassification::Inside + ); + } +} + +#[test] +fn slanted_slab_minus_a_rod_keeps_a_through_hole_with_two_ellipse_rims() { + for degrees in [30.0, 45.0, 60.0] { + let removed = assert_notch(&slanted_block(degrees, 0.25), &rod(), &[0.1], degrees); + let expected = PI * 0.01 * 0.25 / degrees.to_radians().cos(); + assert!((removed - expected).abs() <= 2e-3 * expected, "{removed}"); + } +} + +#[test] +fn slanted_prism_minus_a_half_round_gutter_keeps_a_gutter_shaped_pocket() { + let gutter = extrusion(along_x([0.0; 3]), half_round_edges(), 4.0); + let area = PI * (0.1f64.powi(2) - 0.09f64.powi(2)) / 2.0; + for degrees in [30.0, 45.0, 60.0] { + let removed = assert_notch(&slanted_prism(degrees), &gutter, &[0.1, 0.09], degrees); + assert!((removed - area).abs() <= 2e-3 * area, "{removed}"); + } +} diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs b/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs index e1b7a650..9db2250b 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs @@ -1,3 +1,5 @@ +mod arc_extrusions_half_space; +mod arc_extrusions_notch; mod arc_extrusions_openings; mod arc_extrusions_splits; mod arched_band; diff --git a/main/opengeometry/tests/booleans/coverage_gap.rs b/main/opengeometry/tests/booleans/coverage_gap.rs index 91cc0c02..6d52e7ed 100644 --- a/main/opengeometry/tests/booleans/coverage_gap.rs +++ b/main/opengeometry/tests/booleans/coverage_gap.rs @@ -5,8 +5,9 @@ use opengeometry_test_support::scenes::acceptance::{attempt_rail_minus_wall, rec use opengeometry_test_support::scenes::coverage_gap::rail_minus_wall_clearance; use opengeometry_test_support::scenes::prismatic::box_and_leg; -const GENERIC_HANDLERS: [&str; 6] = [ +const GENERIC_HANDLERS: [&str; 7] = [ "subtract_vertical_arc_extrusion", + "subtract_half_space_from_arc_extrusion", "generic_two_sided_cutter_band_subtraction", "generic_multiple_closed_loops_boolean", "generic_single_closed_loop_boolean", diff --git a/main/opengeometry/tests/booleans/multi_tool.rs b/main/opengeometry/tests/booleans/multi_tool.rs index cac1d928..f2952c5e 100644 --- a/main/opengeometry/tests/booleans/multi_tool.rs +++ b/main/opengeometry/tests/booleans/multi_tool.rs @@ -277,6 +277,7 @@ fn mixed_cutter_batch_gap_names_the_failing_cylindrical_step_gap() { "separate_boolean", "cylinder_box_boolean", "subtract_vertical_arc_extrusion", + "subtract_half_space_from_arc_extrusion", "generic_two_sided_cutter_band_subtraction", "generic_multiple_closed_loops_boolean", "generic_single_closed_loop_boolean", diff --git a/main/opengeometry/tests/booleans/wc_subtract.rs b/main/opengeometry/tests/booleans/wc_subtract.rs index d225979d..83c00973 100644 --- a/main/opengeometry/tests/booleans/wc_subtract.rs +++ b/main/opengeometry/tests/booleans/wc_subtract.rs @@ -6,10 +6,11 @@ use opengeometry_test_support::stored; use opengeometry_test_support::volume; use sha2::{Digest, Sha256}; -const SPECIALIZED: [&str; 4] = [ +const SPECIALIZED: [&str; 5] = [ "separate_boolean", "boolean_spheres", "subtract_vertical_arc_extrusion", + "subtract_half_space_from_arc_extrusion", "generic_two_sided_cutter_band_subtraction", ]; diff --git a/main/opengeometry/tests/creating/revolve.rs b/main/opengeometry/tests/creating/revolve.rs index d013fe50..f1e7041f 100644 --- a/main/opengeometry/tests/creating/revolve.rs +++ b/main/opengeometry/tests/creating/revolve.rs @@ -194,6 +194,7 @@ fn a_revolve_minus_a_cuboid_is_a_coverage_gap_naming_the_handlers_tried() { "separate_boolean", "sphere_box_boolean", "subtract_vertical_arc_extrusion", + "subtract_half_space_from_arc_extrusion", "generic_two_sided_cutter_band_subtraction", "generic_multiple_closed_loops_boolean", "generic_single_closed_loop_boolean", diff --git a/main/opengeometry/tests/creating/revolved.rs b/main/opengeometry/tests/creating/revolved.rs index d1e96404..007fca17 100644 --- a/main/opengeometry/tests/creating/revolved.rs +++ b/main/opengeometry/tests/creating/revolved.rs @@ -183,6 +183,7 @@ fn torus_minus_a_crossing_cuboid_is_a_coverage_gap_naming_the_torus_and_cuboid() "separate_boolean", "torus_box_containment", "subtract_vertical_arc_extrusion", + "subtract_half_space_from_arc_extrusion", "generic_two_sided_cutter_band_subtraction", "generic_multiple_closed_loops_boolean", "generic_single_closed_loop_boolean", diff --git a/main/opengeometry/tests/errors.rs b/main/opengeometry/tests/errors.rs index a5ce3926..61498a11 100644 --- a/main/opengeometry/tests/errors.rs +++ b/main/opengeometry/tests/errors.rs @@ -252,6 +252,7 @@ fn multi_tool_operate_failure_carries_handlers_tool_index_and_og_ids() { "separate_boolean", "cylinder_box_boolean", "subtract_vertical_arc_extrusion", + "subtract_half_space_from_arc_extrusion", "generic_two_sided_cutter_band_subtraction", "generic_multiple_closed_loops_boolean", "generic_single_closed_loop_boolean", diff --git a/main/opengeometry/tests/fixtures/cases/half-space/revolved-bowl.json b/main/opengeometry/tests/fixtures/cases/half-space/revolved-bowl.json index a1ea2f4f..70376bf7 100644 --- a/main/opengeometry/tests/fixtures/cases/half-space/revolved-bowl.json +++ b/main/opengeometry/tests/fixtures/cases/half-space/revolved-bowl.json @@ -3,6 +3,7 @@ "separate_boolean", "sphere_box_boolean", "subtract_vertical_arc_extrusion", + "subtract_half_space_from_arc_extrusion", "generic_two_sided_cutter_band_subtraction", "generic_multiple_closed_loops_boolean", "generic_single_closed_loop_boolean", diff --git a/main/opengeometry/tests/fixtures/cases/half-space/ring.json b/main/opengeometry/tests/fixtures/cases/half-space/ring.json index e488eee4..33bf3083 100644 --- a/main/opengeometry/tests/fixtures/cases/half-space/ring.json +++ b/main/opengeometry/tests/fixtures/cases/half-space/ring.json @@ -1,6 +1,6 @@ { "faces": 8, "handlers": [ - "prismatic_curved_boolean" + "subtract_half_space_from_arc_extrusion" ] } diff --git a/main/opengeometry/tests/fixtures/cases/half-space/torus.json b/main/opengeometry/tests/fixtures/cases/half-space/torus.json index e7b97432..c3b5ef27 100644 --- a/main/opengeometry/tests/fixtures/cases/half-space/torus.json +++ b/main/opengeometry/tests/fixtures/cases/half-space/torus.json @@ -3,6 +3,7 @@ "separate_boolean", "torus_box_containment", "subtract_vertical_arc_extrusion", + "subtract_half_space_from_arc_extrusion", "generic_two_sided_cutter_band_subtraction", "generic_multiple_closed_loops_boolean", "generic_single_closed_loop_boolean", diff --git a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json index fc949e06..f2836e8e 100644 --- a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json +++ b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json @@ -97,11 +97,11 @@ "booleans.curved.wall-arched-door": "ae17a0d478e4eecd2fba54cefeb99742b337f63d742368aea9c4b40d93a70c41", "booleans.curved.wall-arched-opening": "9e0e3b3a7803956d7b2daea34340cc70349188e06d9e57b8f501cd61ea59de8c", "booleans.cylinder-box.oblique-20.intersection": "b3ca091c70b65ad996211459c1a7fbca685a470d29a92139ce18a037ac814a6d", - "booleans.cylinder-box.oblique-20.reversed-subtraction": "c6b884bc5aa61e46b80f14843ba2a08337e2c46a88132c5fc3c20653030dfe23", + "booleans.cylinder-box.oblique-20.reversed-subtraction": "37640edf74c934f68fa87db8baa56377a8ec30d6c9eee0826187a30e57740742", "booleans.cylinder-box.oblique-20.subtraction": "fb70c3918271890ad8550d7298630cf2529dc1ba065218c70f930d05c00c741a", "booleans.cylinder-box.oblique-20.union": "f5186b9c30d873b5ddfc60968533e6e0ee7c34a67e25e28a05d5a47082ba48d9", "booleans.cylinder-box.oblique-45.intersection": "d262c53865b355c60df73c5aefcebea1b90a0199a030b0220cb053d42e6395c1", - "booleans.cylinder-box.oblique-45.reversed-subtraction": "26d63cae1271ec467b9c41529ce9b4ee4690abfe41e884b2bb4211e3c7949870", + "booleans.cylinder-box.oblique-45.reversed-subtraction": "4ea9f00b0e0486e660cec0282168b5ea75181aa9cd1fba3137973ac5c7063556", "booleans.cylinder-box.oblique-45.subtraction": "c69be15416a22ffeaebe9ecc4281880e93f12ce9b21b79b6a3f72bb044a8dce3", "booleans.cylinder-box.oblique-45.union": "df8e2cdb97c79a83de6964c7f6605c814c55bab497663b4df6ae04878c05b977", "booleans.cylinder-box.oblique-70.intersection": "8994caa3dbcd05dbeb1a1e28b3a958d4558a5cc965c6e04364cf628f0fce1af7", @@ -205,11 +205,11 @@ "booleans.generic.sphere-cone.subtraction": "3d2a312400fa15a377b0e27957a7717d9a2e1ffab0d407fe4d4447d42df05ae5", "booleans.generic.sphere-cone.union": "ea400fc87a7d0dcb275d6d0173ed7544e957fb8d5d248c0eb5540fb35e68ab62", "booleans.half-space.cylinder-sector": "fd23acf3f1998d9f38128d1360a4206046d17976c1ab4bd7bf6d52f4bb8d93e6", - "booleans.half-space.revolved-bowl": "8f8a10510873b378cc3f579cdad9f9545fc0b976152ae058fcc18870081e44eb", - "booleans.half-space.ring": "2625aaf936b0a6b6a8b3a7af256f38964de48068c85d8396b309317bbc270429", + "booleans.half-space.revolved-bowl": "3d8b0fcf7ed83c3c5a7c2d19d5c6b7dd4151619dec06f35e88b67267c4d68e8f", + "booleans.half-space.ring": "694b4490e5b688ec2a1716dc75c7ec9730db2d4906352cd674bda941588712e5", "booleans.half-space.sweep-elbow": "3e7b831dfacc2154b05c87bff9071197327affe5c68fb8817ceb41dc77c905b8", "booleans.half-space.tilted-cuboid": "6b5d1c869edc95a39509532e7bc74980d883afda5e4c44c274767d483119f9fe", - "booleans.half-space.torus": "69313ed67adfc6d33a063bdb9fb65422e79307a89fa8d344c3a2c901d2b92726", + "booleans.half-space.torus": "25698315574d7c36dff474746a0635555cf3e89737402d596c18d0b70e9e0f6e", "booleans.matrix.annular-first": "33a63c22fa7c2862039516bbb89dc2bec4bbd65dfc5071504f79edbf08d0d6b8", "booleans.matrix.box-intersection": "a577bebc8cf5d95449144b90df7fc2aee9da38d5f61944a392f1c4cca6d313bf", "booleans.matrix.box-round": "4c129a385d3ca5a0162cd26c2f92da08d8d2d672d93aee3747b665edd7c7f4fb", @@ -517,7 +517,7 @@ "fallback.planar.oblique-cutters": "ebf7d29ba112b6db5bee0b311d5600e1ed85b7f7454a21d2bedcc89818bae40d", "fallback.planar.overlapping-cutters": "718b20930f316734b5bdb2d05191c2896060622972632b2b0b688ed961ee2400", "fallback.prismatic.single-round": "e97d38f09a38c3ac13e1aa0b9e4cedbd0b63cdbb433f632614260f0f9ce403a8", - "graph.acceptance-session": "4d2b33d06347c7bbc3bd5bdb0e3f19b72ef57e2a3bdd15fc8d194728a357592d", + "graph.acceptance-session": "dfdb9331782e8fc1111662c2c08c0dd05a71da73ce5454cf0f75a57d69692033", "graph.operate.box-subtraction": "682775d34f0a00a695ee8cfa2b4017a9d6f97e24fecea56d4759a1ed06a64915", "graph.operate.corotated-box-0": "d5282ecba03ce703bf9e775ce4512df38a9bfb64556aec4591396ecd3142c7c1", "graph.operate.corotated-box-30": "b776edae427b0ddaa3d14e03f05c6275a9dbc2e1b9a8a555df3d0da235633f4f", @@ -549,8 +549,8 @@ "graph.scenario.reparenting": "a98d8485874040b931f50155a2c61ab0f08b11a17b5cdc017d233d099c426921", "graph.scenario.shared-rebuild": "b780cbc13d3a6cd62a50c332b04368fd8d5e74d577bc9f24845740474fa52273", "graph.scenario.subtree-dispose": "3393656291d922a7f7206b51849264b6e68092170b8a6f67849135ea6179f00b", - "handlers-covered": "3b879c7ba799f678660ebfab0d91220039d0f534a9afc0efe6ad0aa012e529d2", - "handlers-expected": "d2a2a8fdbac778a10faba5007ca1da75d1f63e07231c5ba4f5f44620cb57804e", + "handlers-covered": "030a1a0339feadd85af1defddb6ece7c581de76c98590797fffdbf08175ad301", + "handlers-expected": "bb1a8ac498d44057977d2adef9538e3915541c6671de86a166dc5c8c6466a584", "handlers-uncovered": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "level.step.metre.y": "3bfe75db34f9ee266e502be4b3b641abd5d26b3a51c1bf8657f41252ee614f49", "level.step.metre.z": "6108f7b562c88424cd12180dcb82584d60810117a73b36b6ac410bfcdbabb0c2",