From 6270c659c83055f98fd0199644373fc97ff4ac47 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 14 Nov 2026 22:09:11 +0100 Subject: [PATCH 1/5] test: subtract a slanted half-space from arc-edged extrusions Half-round, full-round and ogee extrusions and a primitive cylinder lying on their side, cut by a slanted cuboid or parallelogram prism at 30, 45 and 60 degrees in plan, and two gutters mitred on a corner bisector, singly and as a two-cutter batch. Each result must be closed and valid, keep its cylinder faces analytic and carry an exact ellipse trim. Today every case is a coverage gap. --- .../tests/arc_extrusions_half_space.rs | 347 ++++++++++++++++++ .../operations/modifying/boolean/tests/mod.rs | 1 + 2 files changed, 348 insertions(+) create mode 100644 main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_half_space.rs 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..22aecbf0 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_half_space.rs @@ -0,0 +1,347 @@ +use super::support::volume_at_deflection; +use crate::brep::{ + fine_accuracy, BrepEnvelope, CurveGeometry, EdgeGeometry, 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; + +const HANDLER: &str = "subtract_half_space_from_arc_extrusion"; + +fn along_x(origin: Point3) -> Frame3 { + Frame3 { + origin, + x: [0.0, 1.0, 0.0], + y: [0.0, 0.0, 1.0], + z: [1.0, 0.0, 0.0], + } +} + +fn along_y(origin: Point3) -> Frame3 { + Frame3 { + origin, + x: [-1.0, 0.0, 0.0], + y: [0.0, 0.0, 1.0], + z: [0.0, 1.0, 0.0], + } +} + +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], + }, + ] +} + +fn extrusion(frame: Frame3, edges: Vec, length: f64) -> BrepEnvelope { + primitives::arc_edged_extrusion("gutter".into(), frame, edges, length, fine_accuracy()).unwrap() +} + +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() +} + +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)], + ) +} + +fn slanted_cuboid(degrees: 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], + }, + [10.0, 10.0, 2.0], + fine_accuracy(), + ) + .unwrap() +} + +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}"); +} diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs b/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs index ce775760..533ee4fa 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs @@ -1,3 +1,4 @@ +mod arc_extrusions_half_space; mod arc_extrusions_openings; mod arc_extrusions_splits; mod arched_band; From c8bc68581a0707d0c1e406073347d4df4109443a Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 14 Nov 2026 23:01:19 +0100 Subject: [PATCH 2/5] Subtract a slanted half-space from an arc-edged extrusion A planar cutter that crosses an extrusion of lines and arcs at an angle to its axis fell through every handler as a coverage gap, so gutters and rods could not be mitred. The new handler reads the host as a straight prism from its two caps, finds the one convex-cutter face that crosses every generator strictly between the caps while the cutter holds all of the removed end, and rebuilds the prism with that face as its new cap: cylinder sides stay analytic and each arc gets an exact ellipse trim. A batch of such cutters replaces one cap per cutter, so a gutter mitred at both ends is one result. It sits right after the vertical arc-extrusion handler, so the stored half-space ring case (a cube with a round bore under the section clipper) now takes this handler instead of prismatic_curved_boolean, with the same eight faces, a valid solid and the same half volume. Every other change to recorded results is the new name in attempt lists: the half-space torus and revolved-bowl outcomes, the rail-minus-wall gap in the acceptance session, and the golden handler coverage lists. --- .../opengeometry/src/brep/builder/boundary.rs | 9 + .../operations/modifying/boolean/batch/mod.rs | 54 +- .../modifying/boolean/dispatch/handler_id.rs | 2 + .../modifying/boolean/dispatch/table.rs | 34 +- .../generic/half_space_extrusion/build.rs | 463 ++++++++++++++++++ .../generic/half_space_extrusion/cut.rs | 134 +++++ .../generic/half_space_extrusion/mod.rs | 54 ++ .../generic/half_space_extrusion/prism.rs | 352 +++++++++++++ .../modifying/boolean/handlers/generic/mod.rs | 4 + .../modifying/boolean/handlers/mod.rs | 3 +- .../tests/arc_extrusions_half_space.rs | 14 +- .../tests/booleans/coverage_gap.rs | 3 +- .../opengeometry/tests/booleans/multi_tool.rs | 1 + .../tests/booleans/wc_subtract.rs | 3 +- main/opengeometry/tests/creating/revolve.rs | 1 + main/opengeometry/tests/creating/revolved.rs | 1 + main/opengeometry/tests/errors.rs | 1 + .../cases/half-space/revolved-bowl.json | 1 + .../tests/fixtures/cases/half-space/ring.json | 2 +- .../fixtures/cases/half-space/torus.json | 1 + .../fixtures/golden/aarch64-apple-darwin.json | 12 +- 21 files changed, 1110 insertions(+), 39 deletions(-) create mode 100644 main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/build.rs create mode 100644 main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/cut.rs create mode 100644 main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/mod.rs create mode 100644 main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/prism.rs 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 d8930d21..5f854bb4 100644 --- a/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs +++ b/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs @@ -22,6 +22,7 @@ pub(crate) enum HandlerId { SubtractPlanarPolyhedra, BooleanSpheres, SubtractVerticalArcExtrusion, + SubtractHalfSpaceFromArcExtrusion, GenericTwoSidedCutterBandSubtraction, GenericMultipleClosedLoopsBoolean, GenericSingleClosedLoopBoolean, @@ -57,6 +58,7 @@ impl HandlerId { Self::SubtractPlanarPolyhedra => "subtract_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 72270b83..1c915bb6 100644 --- a/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs +++ b/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs @@ -8,9 +8,9 @@ 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_layered_extrusions, subtract_planar_polyhedra, subtract_vertical_arc_extrusion, - torus_box_containment, torus_conic_containment, torus_containment_boolean, - torus_cylinder_containment, + subtract_half_space_from_arc_extrusion, subtract_layered_extrusions, subtract_planar_polyhedra, + subtract_vertical_arc_extrusion, torus_box_containment, torus_conic_containment, + torus_containment_boolean, torus_cylinder_containment, }; use crate::operations::modifying::boolean::operands::all_planar; use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; @@ -250,6 +250,28 @@ 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> { + 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) @@ -261,7 +283,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), @@ -273,6 +295,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/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..5be8f62c --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/build.rs @@ -0,0 +1,463 @@ +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, +} + +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 = prism + .loops + .iter() + .map(|spans| add_ring(&mut builder, prism, spans)) + .collect::, _>>()?; + 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(), + }; + match span.edge { + ProfileEdge::Line { .. } => add_plane_side(&mut builder, prism, &side)?, + ProfileEdge::Arc { .. } => add_cylinder_side(&mut builder, prism, &side)?, + } + } + } + add_cap(&mut builder, prism, &rings, 1)?; + add_cap(&mut builder, prism, &rings, 0)?; + builder.finish_solid() +} + +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, + ), + ] +} + +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<'_>, +) -> Result<(), GeometryError> { + 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], + face_provenance(vec![side.span.source.clone()], FaceRole::Split, false), + )?; + Ok(()) +} + +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<'_>, +) -> Result<(), GeometryError> { + 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], + face_provenance(vec![side.span.source.clone()], FaceRole::Split, false), + )?; + if sweep_angle < 0.0 { + builder.brep.topology.faces[face as usize].sense = Orientation::Reverse; + } + Ok(()) +} + +fn add_cap( + builder: &mut Builder, + prism: &Prism, + rings: &[Ring], + cap: usize, +) -> Result<(), GeometryError> { + 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)?; + 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), + )?; + Ok(()) +} + +fn cap_loops( + builder: &Builder, + frame: Frame3, + rings: &[Ring], + cap: usize, +) -> 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 cap == 1 { + 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 cap == 0 { + 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..4ae25a23 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/cut.rs @@ -0,0 +1,134 @@ +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; + +struct Plane { + point: Point3, + outward: Point3, + 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()) +} + +fn profile(prism: &Prism) -> impl Iterator { + prism.loops.iter().flatten().map(|span| &span.edge) +} + +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], + ], + } +} + +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..32a89716 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/mod.rs @@ -0,0 +1,54 @@ +mod build; +mod cut; +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 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; + } + let mut out = build(&prism, id, accuracy)?; + out.revision = cutters + .iter() + .fold(host.revision, |revision, cutter| { + revision.max(cutter.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(cutters.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/prism.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/prism.rs new file mode 100644 index 00000000..f7fa6840 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/prism.rs @@ -0,0 +1,352 @@ +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()), + } +} + +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()); + } + } +} + +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..6d6d587e 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,9 @@ mod single_closed_loop; mod tests; mod two_sided_cutter_band; +pub(crate) use half_space_extrusion::{ + 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 aa1a9eab..f3b21b65 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_half_space_from_arc_extrusion, + subtract_half_spaces_from_arc_extrusion, }; pub(super) use planar::{subtract_layered_extrusions, subtract_planar_polyhedra}; 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 index 22aecbf0..a6a76847 100644 --- 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 @@ -13,21 +13,11 @@ use std::f64::consts::PI; const HANDLER: &str = "subtract_half_space_from_arc_extrusion"; fn along_x(origin: Point3) -> Frame3 { - Frame3 { - origin, - x: [0.0, 1.0, 0.0], - y: [0.0, 0.0, 1.0], - z: [1.0, 0.0, 0.0], - } + Frame3::from_axis(origin, [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]).unwrap() } fn along_y(origin: Point3) -> Frame3 { - Frame3 { - origin, - x: [-1.0, 0.0, 0.0], - y: [0.0, 0.0, 1.0], - z: [0.0, 1.0, 0.0], - } + Frame3::from_axis(origin, [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]).unwrap() } fn half_round_edges() -> Vec { 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 6e359a54..5227697c 100644 --- a/main/opengeometry/tests/errors.rs +++ b/main/opengeometry/tests/errors.rs @@ -242,6 +242,7 @@ fn multi_tool_operate_failure_carries_handlers_tool_index_and_og_ids() { "boolean_planar_extrusions", "boolean_rectilinear", "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 1f59b51f..924e6100 100644 --- a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json +++ b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json @@ -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": "786eb944d6f4ae8bb198fa31a8ed05290b2d8940aff4acaaeed14f2408010868", - "handlers-expected": "9c692231727b9c7acfc2c0303b66ccb7ec4a3b3c3d014ad848c8fc2a64ee83c5", + "handlers-covered": "a8784d3ee87d13dc37edcd988e62ae786add21ff14e775bbca0a758f4ae82372", + "handlers-expected": "899cb4d902facdf3469626897077124c4b38d5f821b823444f7991df1ca3362e", "handlers-uncovered": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "level.step.metre.y": "3bfe75db34f9ee266e502be4b3b641abd5d26b3a51c1bf8657f41252ee614f49", "level.step.metre.z": "6108f7b562c88424cd12180dcb82584d60810117a73b36b6ac410bfcdbabb0c2", From e6ea670ca395df83a4afd9e1c0e8fef50ae47d7e Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 14 Nov 2026 23:07:03 +0100 Subject: [PATCH 3/5] test: the mitre end is bounded by exact half ellipses Drawings take the cut end from the B-rep's ellipse edges, so the end face itself must be bounded by ellipse arcs that lie on both the bisector plane and the gutter cylinders and cover the whole half-round. Red on the code before the half-space handler (a coverage gap), green after. --- .../tests/arc_extrusions_half_space.rs | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) 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 index a6a76847..69b4bce1 100644 --- 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 @@ -1,6 +1,6 @@ use super::support::volume_at_deflection; use crate::brep::{ - fine_accuracy, BrepEnvelope, CurveGeometry, EdgeGeometry, Frame3, SurfaceGeometry, + fine_accuracy, BrepEnvelope, CurveGeometry, EdgeGeometry, FaceRole, Frame3, SurfaceGeometry, }; use crate::math::{dot, Point3}; use crate::operations::modifying::boolean::batch::subtract_planar_cutters_with_handlers; @@ -335,3 +335,66 @@ fn a_gutter_mitred_at_both_ends_in_one_batch_keeps_its_cylinders() { 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); +} From cca679390f45c59924b23af48e3433227cb6b67b Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 14 Nov 2026 23:18:15 +0100 Subject: [PATCH 4/5] test: subtract a slanted arc extrusion from a planar solid A slanted cuboid minus a rod must keep a blind round hole with an ellipse rim, a slanted slab minus a rod a through hole with two rims, and a slanted prism minus a half-round gutter a gutter-shaped pocket, each valid and closed with analytic cylinder walls and the right removed volume. Today each is a coverage gap. The tilted cuboid minus cylinder case leaves the coverage-gap test, which now holds only for their union. --- .../handlers/cylinder_box/tests/tilt.rs | 22 ++--- .../tests/arc_extrusions_half_space.rs | 16 +-- .../boolean/tests/arc_extrusions_notch.rs | 99 +++++++++++++++++++ .../operations/modifying/boolean/tests/mod.rs | 1 + 4 files changed, 117 insertions(+), 21 deletions(-) create mode 100644 main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_notch.rs 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/tests/arc_extrusions_half_space.rs b/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_half_space.rs index 69b4bce1..7771f946 100644 --- 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 @@ -10,9 +10,9 @@ use crate::primitives::{self, ProfileEdge}; use crate::query::{classify_point, PointClassification}; use std::f64::consts::PI; -const HANDLER: &str = "subtract_half_space_from_arc_extrusion"; +pub(super) const HANDLER: &str = "subtract_half_space_from_arc_extrusion"; -fn along_x(origin: Point3) -> Frame3 { +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() } @@ -20,7 +20,7 @@ fn along_y(origin: Point3) -> Frame3 { Frame3::from_axis(origin, [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]).unwrap() } -fn half_round_edges() -> Vec { +pub(super) fn half_round_edges() -> Vec { vec![ ProfileEdge::Arc { center: [0.0, 0.0], @@ -91,11 +91,11 @@ fn ogee_edges() -> Vec { ] } -fn extrusion(frame: Frame3, edges: Vec, length: f64) -> BrepEnvelope { +pub(super) fn extrusion(frame: Frame3, edges: Vec, length: f64) -> BrepEnvelope { primitives::arc_edged_extrusion("gutter".into(), frame, edges, length, fine_accuracy()).unwrap() } -fn prism_cutter(id: &str, ring: Vec<[f64; 2]>) -> BrepEnvelope { +pub(super) fn prism_cutter(id: &str, ring: Vec<[f64; 2]>) -> BrepEnvelope { primitives::linear_extrusion( id.into(), Frame3 { @@ -110,7 +110,7 @@ fn prism_cutter(id: &str, ring: Vec<[f64; 2]>) -> BrepEnvelope { .unwrap() } -fn slanted_prism(degrees: f64) -> BrepEnvelope { +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]]; @@ -120,7 +120,7 @@ fn slanted_prism(degrees: f64) -> BrepEnvelope { ) } -fn slanted_cuboid(degrees: f64) -> BrepEnvelope { +pub(super) fn slanted_cuboid(degrees: f64) -> BrepEnvelope { let t = degrees.to_radians(); let x = [t.cos(), t.sin(), 0.0]; let y = [-t.sin(), t.cos(), 0.0]; @@ -138,7 +138,7 @@ fn slanted_cuboid(degrees: f64) -> BrepEnvelope { .unwrap() } -fn cut_ellipses(brep: &BrepEnvelope) -> Vec<(Point3, f64, f64)> { +pub(super) fn cut_ellipses(brep: &BrepEnvelope) -> Vec<(Point3, f64, f64)> { brep.topology .edges .iter() 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..82ebdbda --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_notch.rs @@ -0,0 +1,99 @@ +use super::arc_extrusions_half_space::{ + along_x, cut_ellipses, extrusion, half_round_edges, slanted_cuboid, slanted_prism, HANDLER, +}; +use super::support::volume_at_deflection; +use crate::brep::{fine_accuracy, BrepEnvelope, Frame3, 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 slanted_slab(degrees: 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-slab".into(), + Frame3 { + origin: [3.0 - 5.0 * y[0], -5.0 * y[1], -1.0], + x, + y, + z: [0.0, 0.0, 1.0], + }, + [0.25, 10.0, 2.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_slab(degrees), &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 533ee4fa..5212b3fe 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs @@ -1,4 +1,5 @@ mod arc_extrusions_half_space; +mod arc_extrusions_notch; mod arc_extrusions_openings; mod arc_extrusions_splits; mod arched_band; From b85b0ca1b2bce82c753a42717f011938ecceb2e9 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 14 Nov 2026 23:47:39 +0100 Subject: [PATCH 5/5] Subtract a slanted arc extrusion from a convex planar solid The reverse order of the half-space cut, a cuboid or prism minus a rod or gutter lying across one of its faces at a slant, was still a coverage gap. The prism model is trimmed by every face of the convex host it crosses cleanly, until each remaining face holds the trimmed piece inside it. The host is then copied with the trimmed piece's ends as holes in the faces it enters through. The piece's walls are reversed into the host, and an end of the rod that stays inside becomes the floor of a blind hole. Rims are exact ellipses and walls stay analytic cylinders. Holes square to the axis are left to the existing handlers, and so is a face that also cuts the rod's end discs. It shares the half-space handler's dispatch entry, so no attempt list moves. The golden cylinder-box oblique-20 and oblique-45 reversed subtractions, recorded as coverage gaps, now return a nine-face result; oblique-70 stays a gap because that face reaches past both ends of the cylinder. --- .../modifying/boolean/dispatch/table.rs | 12 +- .../generic/half_space_extrusion/build.rs | 74 ++++--- .../generic/half_space_extrusion/cut.rs | 17 +- .../generic/half_space_extrusion/mod.rs | 28 ++- .../generic/half_space_extrusion/notch.rs | 201 ++++++++++++++++++ .../generic/half_space_extrusion/prism.rs | 7 +- .../modifying/boolean/handlers/generic/mod.rs | 3 +- .../modifying/boolean/handlers/mod.rs | 4 +- .../tests/arc_extrusions_half_space.rs | 6 +- .../boolean/tests/arc_extrusions_notch.rs | 25 +-- .../fixtures/golden/aarch64-apple-darwin.json | 4 +- 11 files changed, 308 insertions(+), 73 deletions(-) create mode 100644 main/opengeometry/src/operations/modifying/boolean/handlers/generic/half_space_extrusion/notch.rs diff --git a/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs b/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs index 1c915bb6..6fc4a97e 100644 --- a/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs +++ b/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs @@ -8,9 +8,10 @@ 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_half_space_from_arc_extrusion, subtract_layered_extrusions, subtract_planar_polyhedra, - subtract_vertical_arc_extrusion, torus_box_containment, torus_conic_containment, - torus_containment_boolean, torus_cylinder_containment, + subtract_arc_extrusion_from_planar_solid, subtract_half_space_from_arc_extrusion, + subtract_layered_extrusions, subtract_planar_polyhedra, subtract_vertical_arc_extrusion, + torus_box_containment, torus_conic_containment, torus_containment_boolean, + torus_cylinder_containment, }; use crate::operations::modifying::boolean::operands::all_planar; use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; @@ -262,6 +263,11 @@ fn maybe_half_space_arc_extrusion( 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) }) 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 index 5be8f62c..c6a6e398 100644 --- 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 @@ -24,7 +24,7 @@ struct CapEdge { conic: Option, } -struct Ring { +pub(super) struct Ring { vertices: [Vec; 2], edges: [Vec; 2], generators: Vec, @@ -43,12 +43,30 @@ pub(super) fn build( accuracy: Accuracy, ) -> Result { let mut builder = Builder::new(id, accuracy)?; - let rings = prism + 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(&mut builder, prism, spans)) - .collect::, _>>()?; - for (spans, ring) in prism.loops.iter().zip(&rings) { + .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, @@ -56,15 +74,14 @@ pub(super) fn build( index, next: (index + 1) % spans.len(), }; - match span.edge { - ProfileEdge::Line { .. } => add_plane_side(&mut builder, prism, &side)?, - ProfileEdge::Arc { .. } => add_cylinder_side(&mut builder, prism, &side)?, - } + 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)?, + }); } } - add_cap(&mut builder, prism, &rings, 1)?; - add_cap(&mut builder, prism, &rings, 0)?; - builder.finish_solid() + Ok(faces) } fn cap_point(prism: &Prism, cap: usize, q: [f64; 2]) -> Point3 { @@ -232,7 +249,7 @@ fn side_steps(side: &Side<'_>) -> [(u32, u32, u32, Orientation); 4] { ] } -fn add_face( +pub(super) fn add_face( builder: &mut Builder, label: &str, surface: SurfaceGeometry, @@ -258,7 +275,8 @@ fn add_plane_side( builder: &mut Builder, prism: &Prism, side: &Side<'_>, -) -> Result<(), GeometryError> { + 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 { @@ -284,9 +302,8 @@ fn add_plane_side( SurfaceGeometry::Plane { frame }, bounds, vec![uses], - face_provenance(vec![side.span.source.clone()], FaceRole::Split, false), - )?; - Ok(()) + provenance, + ) } fn cylinder_pcurve(conic: &Conic, surface: u32) -> PcurveGeometry { @@ -308,7 +325,8 @@ fn add_cylinder_side( builder: &mut Builder, prism: &Prism, side: &Side<'_>, -) -> Result<(), GeometryError> { + provenance: FaceProvenance, +) -> Result { let ProfileEdge::Arc { center, radius, @@ -361,20 +379,20 @@ fn add_cylinder_side( }, bounds, vec![uses], - face_provenance(vec![side.span.source.clone()], FaceRole::Split, false), + provenance, )?; if sweep_angle < 0.0 { builder.brep.topology.faces[face as usize].sense = Orientation::Reverse; } - Ok(()) + Ok(face) } -fn add_cap( +pub(super) fn add_cap( builder: &mut Builder, prism: &Prism, rings: &[Ring], cap: usize, -) -> Result<(), GeometryError> { +) -> Result { let Cap { point, normal, @@ -391,7 +409,7 @@ fn add_cap( y: cross(*normal, x), z: *normal, }; - let loops = cap_loops(builder, frame, rings, cap)?; + 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: [ @@ -416,15 +434,15 @@ fn add_cap( bounds, loops, face_provenance(vec![cap_ref.source.clone()], cap_ref.role, cap_ref.reversed), - )?; - Ok(()) + ) } -fn cap_loops( +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 { @@ -434,7 +452,7 @@ fn cap_loops( let next = (index + 1) % count; let edge = &ring.edges[cap][index]; let vertices = &ring.vertices[cap]; - uses.push(if cap == 1 { + uses.push(if along { plane_boundary( builder, frame, @@ -454,7 +472,7 @@ fn cap_loops( )? }); } - if cap == 0 { + if !along { uses.reverse(); } loops.push(uses); 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 index 4ae25a23..cab9e792 100644 --- 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 @@ -5,10 +5,10 @@ use crate::operations::modifying::boolean::operands::brep_face_source; use crate::primitives::ProfileEdge; use std::f64::consts::TAU; -struct Plane { - point: Point3, - outward: Point3, - face: u32, +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] { @@ -89,11 +89,11 @@ pub(super) fn cut_cap( Err(gap()) } -fn profile(prism: &Prism) -> impl Iterator { +pub(super) fn profile(prism: &Prism) -> impl Iterator { prism.loops.iter().flatten().map(|span| &span.edge) } -fn plane_at_cap(prism: &Prism, plane: &Plane, cap: Affine) -> Affine { +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 { @@ -105,7 +105,10 @@ fn plane_at_cap(prism: &Prism, plane: &Plane, cap: Affine) -> Affine { } } -fn convex_planes(cutter: &BrepEnvelope, clearance: f64) -> Result, GeometryError> { +pub(super) fn convex_planes( + cutter: &BrepEnvelope, + clearance: f64, +) -> Result, GeometryError> { if cutter.solids.len() != 1 || cutter.topology.shells.len() != 1 { return 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 index 32a89716..c07cd0d1 100644 --- 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 @@ -1,5 +1,6 @@ mod build; mod cut; +mod notch; mod prism; use crate::brep::{Accuracy, BrepEnvelope, GeometryError, GeometryQuality}; @@ -7,6 +8,7 @@ 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( @@ -30,17 +32,31 @@ pub(crate) fn subtract_half_spaces_from_arc_extrusion( let (replaced, cap) = cut_cap(&prism, cutter, 4.0 * accuracy.geometric)?; prism.caps[replaced] = cap; } - let mut out = build(&prism, id, accuracy)?; - out.revision = cutters + 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, cutter| { - revision.max(cutter.revision) - }) + .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(cutters.iter().copied())); + analytic_face_mappings(&out, std::iter::once(host).chain(tools.iter().copied())); Ok(BooleanResult { report: BooleanReport { operation: BooleanOp::Subtraction, 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 index f7fa6840..ff9dff28 100644 --- 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 @@ -150,7 +150,10 @@ fn cap_faces(host: &BrepEnvelope, frame: Frame3) -> Result<[u32; 2], GeometryErr } } -fn loop_halfedges(host: &BrepEnvelope, start: u32) -> Result, GeometryError> { +pub(super) fn loop_halfedges( + host: &BrepEnvelope, + start: u32, +) -> Result, GeometryError> { let mut halfedges = Vec::new(); let mut current = start; loop { @@ -166,7 +169,7 @@ fn loop_halfedges(host: &BrepEnvelope, start: u32) -> Result, Geo } } -fn face_loops(host: &BrepEnvelope, face: u32) -> Vec { +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()) 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 6d6d587e..d00ca4d8 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/mod.rs @@ -13,7 +13,8 @@ mod tests; mod two_sided_cutter_band; pub(crate) use half_space_extrusion::{ - subtract_half_space_from_arc_extrusion, subtract_half_spaces_from_arc_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; diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs index f3b21b65..6bf0e4e8 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs @@ -27,8 +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, subtract_half_space_from_arc_extrusion, - subtract_half_spaces_from_arc_extrusion, + 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::{subtract_layered_extrusions, subtract_planar_polyhedra}; 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 index 7771f946..b8fa6fb3 100644 --- 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 @@ -121,6 +121,10 @@ pub(super) fn slanted_prism(degrees: f64) -> BrepEnvelope { } 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]; @@ -132,7 +136,7 @@ pub(super) fn slanted_cuboid(degrees: f64) -> BrepEnvelope { y, z: [0.0, 0.0, 1.0], }, - [10.0, 10.0, 2.0], + [depth, 10.0, 2.0], fine_accuracy(), ) .unwrap() 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 index 82ebdbda..4d7cced6 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_notch.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/arc_extrusions_notch.rs @@ -1,8 +1,9 @@ use super::arc_extrusions_half_space::{ - along_x, cut_ellipses, extrusion, half_round_edges, slanted_cuboid, slanted_prism, HANDLER, + 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, Frame3, SurfaceGeometry}; +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; @@ -13,24 +14,6 @@ fn rod() -> BrepEnvelope { primitives::cylinder("rod".into(), along_x([0.0; 3]), 0.1, 4.0, fine_accuracy()).unwrap() } -fn slanted_slab(degrees: 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-slab".into(), - Frame3 { - origin: [3.0 - 5.0 * y[0], -5.0 * y[1], -1.0], - x, - y, - z: [0.0, 0.0, 1.0], - }, - [0.25, 10.0, 2.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()) @@ -82,7 +65,7 @@ fn slanted_cuboid_minus_a_rod_keeps_a_blind_round_hole_with_an_ellipse_rim() { #[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_slab(degrees), &rod(), &[0.1], degrees); + 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}"); } diff --git a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json index 924e6100..85fbe381 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",