From b02d230406f9f9145e47e5bb1063e97ea8bfee41 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 3 Nov 2026 21:42:52 +0100 Subject: [PATCH 1/3] Build each face trim once per face pair and stop the disjoint check at the first branch Small curved subtracts, such as a WC pan cut from its shelled body, spent almost all their time rebuilding the same trim polygons on every membership probe, revalidating both bodies for every face pair, and finishing the whole body intersection graph in the non-intersecting handler after the first branch had already decided it. A face trim now holds the sampled loops and band limits of one face and answers the same tests in the same order, rebuilding a loop only where the old code would have failed; the clip builds the two trims once per branch, the body graph validates both bodies once, and the non-intersecting handler stops at the first coincident or branching pair. Results, handlers and attempted lists are unchanged; the WC pan and seat subtracts and the face trim answers are pinned to values recorded before the change. --- .../intersection/face_intersection/clip.rs | 70 ++++----- .../src/intersection/face_intersection/mod.rs | 30 +++- .../intersection/face_intersection/tests.rs | 76 ++++++++- main/opengeometry/src/intersection/mod.rs | 3 +- .../handlers/generic/non_intersecting.rs | 4 +- main/opengeometry/src/query/face_trim.rs | 139 ++++++++++++----- main/opengeometry/src/query/mod.rs | 2 +- main/opengeometry/src/query/tests.rs | 145 +++++++++++++++++- .../test-support/src/scenes/mod.rs | 1 + .../test-support/src/scenes/wc.rs | 118 ++++++++++++++ main/opengeometry/tests/booleans/main.rs | 1 + .../tests/booleans/wc_subtract.rs | 83 ++++++++++ 12 files changed, 584 insertions(+), 88 deletions(-) create mode 100644 main/opengeometry/test-support/src/scenes/wc.rs create mode 100644 main/opengeometry/tests/booleans/wc_subtract.rs diff --git a/main/opengeometry/src/intersection/face_intersection/clip.rs b/main/opengeometry/src/intersection/face_intersection/clip.rs index 1da2d00e..a4c4e4f2 100644 --- a/main/opengeometry/src/intersection/face_intersection/clip.rs +++ b/main/opengeometry/src/intersection/face_intersection/clip.rs @@ -6,7 +6,7 @@ use crate::brep::{ }; use crate::intersection::ssi_result::SsiCurve; use crate::math::Interval; -use crate::query::face_contains_uv; +use crate::query::FaceTrim; struct BranchMembership<'a> { geometry: &'a GeometryStore, @@ -14,6 +14,7 @@ struct BranchMembership<'a> { faces: [(FaceView<'a>, &'a Face); 2], boxes_only: bool, tolerance: f64, + trims: [FaceTrim; 2], } pub(super) fn clip_face_branch( @@ -52,6 +53,10 @@ pub(super) fn clip_face_branch( faces, boxes_only, tolerance: accuracy.intersection, + trims: [ + FaceTrim::new(faces[0].0.brep, faces[0].1)?, + FaceTrim::new(faces[1].0.brep, faces[1].1)?, + ], }; let mut intervals: Vec = Vec::new(); for (segment, pair) in anchors.windows(2).enumerate() { @@ -85,17 +90,12 @@ fn clipped_span( hi: f64, ) -> Result, GeometryError> { let BranchMembership { - geometry, - branch, - faces, - boxes_only, - tolerance, + geometry, branch, .. } = *membership; let middle = 0.5 * (lo + hi); - let lo_inside = branch_inside_faces(geometry, branch, faces, boxes_only, tolerance, lo)?; - let middle_inside = - branch_inside_faces(geometry, branch, faces, boxes_only, tolerance, middle)?; - let hi_inside = branch_inside_faces(geometry, branch, faces, boxes_only, tolerance, hi)?; + let lo_inside = branch_inside_faces(membership, lo)?; + let middle_inside = branch_inside_faces(membership, middle)?; + let hi_inside = branch_inside_faces(membership, hi)?; if !lo_inside && !middle_inside && !hi_inside { if matches!( geometry.curves[branch.curve as usize], @@ -113,16 +113,16 @@ fn clipped_span( let start = if lo_inside { lo } else if middle_inside { - transition_parameter(geometry, branch, faces, boxes_only, lo, middle, tolerance)? + transition_parameter(membership, lo, middle)? } else { - transition_parameter(geometry, branch, faces, boxes_only, middle, hi, tolerance)? + transition_parameter(membership, middle, hi)? }; let end = if hi_inside { hi } else if middle_inside { - transition_parameter(geometry, branch, faces, boxes_only, hi, middle, tolerance)? + transition_parameter(membership, hi, middle)? } else { - transition_parameter(geometry, branch, faces, boxes_only, middle, lo, tolerance)? + transition_parameter(membership, middle, lo)? }; let (start, end) = if start <= end { (start, end) @@ -147,7 +147,7 @@ fn span_ends_on_boundary( .geometry .pcurve_at(membership.branch.pcurves[side], parameter)?; let (view, face) = membership.faces[side]; - touching &= face_contains_uv(view.brep, face, uv)? != Some(false); + touching &= membership.trims[side].contains(view.brep, face, uv)? != Some(false); } if touching { return Ok(true); @@ -157,25 +157,24 @@ fn span_ends_on_boundary( } fn branch_inside_faces( - geometry: &GeometryStore, - branch: &SsiCurve, - faces: [(FaceView<'_>, &Face); 2], - boxes_only: bool, - tolerance: f64, + membership: &BranchMembership, parameter: f64, ) -> Result { + let BranchMembership { + geometry, + branch, + faces, + boxes_only, + tolerance, + .. + } = *membership; for side in 0..2 { let uv = geometry.pcurve_at(branch.pcurves[side], parameter)?; + let (view, face) = faces[side]; let inside = if boxes_only { - uv_in_bounds( - geometry, - side as u32, - faces[side].1.trim.uv_bounds, - uv, - tolerance, - )? + uv_in_bounds(geometry, side as u32, face.trim.uv_bounds, uv, tolerance)? } else { - face_contains_uv(faces[side].0.brep, faces[side].1, uv)? == Some(true) + membership.trims[side].contains(view.brep, face, uv)? == Some(true) }; if !inside { return Ok(false); @@ -214,9 +213,8 @@ fn span_clears_boxes( let BranchMembership { geometry, branch, - faces, - boxes_only, tolerance, + .. } = *membership; let CurveGeometry::Intersection { definition } = geometry.curves[branch.curve as usize] else { return Ok(false); @@ -234,9 +232,7 @@ fn span_clears_boxes( continue; } let middle = 0.5 * (lo + hi); - if depth == 0 - || branch_inside_faces(geometry, branch, faces, boxes_only, tolerance, middle)? - { + if depth == 0 || branch_inside_faces(membership, middle)? { return Ok(false); } spans.push((lo, middle, depth - 1)); @@ -319,17 +315,13 @@ fn box_may_overlap(periods: [Option; 2], tube: [Interval; 2], face_bounds: } fn transition_parameter( - geometry: &GeometryStore, - branch: &SsiCurve, - faces: [(FaceView<'_>, &Face); 2], - boxes_only: bool, + membership: &BranchMembership, mut outside: f64, mut inside: f64, - tolerance: f64, ) -> Result { for _ in 0..64 { let middle = 0.5 * (outside + inside); - if branch_inside_faces(geometry, branch, faces, boxes_only, tolerance, middle)? { + if branch_inside_faces(membership, middle)? { inside = middle; } else { outside = middle; diff --git a/main/opengeometry/src/intersection/face_intersection/mod.rs b/main/opengeometry/src/intersection/face_intersection/mod.rs index 2fc70622..82c65eae 100644 --- a/main/opengeometry/src/intersection/face_intersection/mod.rs +++ b/main/opengeometry/src/intersection/face_intersection/mod.rs @@ -43,6 +43,21 @@ pub(crate) struct BodyIntersectionGraph { pub(crate) fn intersect_breps( a: &BrepEnvelope, b: &BrepEnvelope, +) -> Result { + body_intersection(a, b, false) +} + +pub(crate) fn intersect_breps_until_branch( + a: &BrepEnvelope, + b: &BrepEnvelope, +) -> Result { + body_intersection(a, b, true) +} + +fn body_intersection( + a: &BrepEnvelope, + b: &BrepEnvelope, + until_branch: bool, ) -> Result { a.validate()?; b.validate()?; @@ -65,7 +80,7 @@ pub(crate) fn intersect_breps( if !bounds_a.overlaps(*bounds_b, accuracy.intersection) { continue; } - let graph = intersect_faces( + let graph = intersect_validated_faces( FaceView { brep: a, face: face_a.id, @@ -84,12 +99,16 @@ pub(crate) fn intersect_breps( } other => other, })?; - if graph.coincident || !graph.branches.is_empty() || !graph.contacts.is_empty() { + let branching = graph.coincident || !graph.branches.is_empty(); + if branching || !graph.contacts.is_empty() { pairs.push(FacePairIntersection { faces: [face_a.id, face_b.id], graph, }); } + if until_branch && branching { + return Ok(BodyIntersectionGraph { pairs }); + } } } Ok(BodyIntersectionGraph { pairs }) @@ -101,6 +120,13 @@ pub(crate) fn intersect_faces( ) -> Result { a.brep.validate()?; b.brep.validate()?; + intersect_validated_faces(a, b) +} + +fn intersect_validated_faces( + a: FaceView<'_>, + b: FaceView<'_>, +) -> Result { let face_a = a.brep.topology.faces.get(a.face as usize).ok_or_else(|| { GeometryError::MissingReference { kind: "face".into(), diff --git a/main/opengeometry/src/intersection/face_intersection/tests.rs b/main/opengeometry/src/intersection/face_intersection/tests.rs index 40e5b68f..53d494fb 100644 --- a/main/opengeometry/src/intersection/face_intersection/tests.rs +++ b/main/opengeometry/src/intersection/face_intersection/tests.rs @@ -1,6 +1,6 @@ use super::clip::piece_box; use super::face_view::FaceView; -use super::{intersect_breps, intersect_faces}; +use super::{intersect_breps, intersect_breps_until_branch, intersect_faces, FacePairIntersection}; use crate::brep::{ coarse_accuracy, Accuracy, Curve, CurveGeometry, Frame3, GeometryStore, IntersectionDefinition, IntersectionSide, PcurveGeometry, Surface, SurfaceGeometry, TraceAnchor, @@ -304,3 +304,77 @@ fn piece_box_encloses_an_axial_peak_between_its_end_samples() { "{piece:?} misses {peak:?}" ); } + +fn faces_of(pairs: &[FacePairIntersection]) -> Vec<[u32; 2]> { + pairs.iter().map(|pair| pair.faces).collect() +} + +#[test] +fn the_until_branch_body_graph_ends_at_the_first_branching_pair_of_a_cuboid_and_a_tilted_cylinder() +{ + let (sin, cos) = 10.0_f64.to_radians().sin_cos(); + let tilted = Frame3 { + origin: [0.5, -0.1, 0.0], + x: [1.0, 0.0, 0.0], + y: [0.0, cos, sin], + z: [0.0, -sin, cos], + }; + let block = Frame3 { + origin: [-0.5, -0.5, 0.0], + ..Frame3::IDENTITY + }; + let host = primitives::cuboid("host".into(), block, [1.0, 1.0, 0.5], Accuracy::STANDARD); + let leg = primitives::cylinder("leg".into(), tilted, 0.1, 0.7, Accuracy::STANDARD); + let (host, leg) = (host.unwrap(), leg.unwrap()); + let full = intersect_breps(&host, &leg).unwrap(); + let until = intersect_breps_until_branch(&host, &leg).unwrap(); + let first = full + .pairs + .iter() + .position(|pair| pair.graph.coincident || !pair.graph.branches.is_empty()) + .unwrap(); + assert!(first + 1 < full.pairs.len()); + assert_eq!(faces_of(&until.pairs), faces_of(&full.pairs[..=first])); +} + +#[test] +fn the_until_branch_body_graph_returns_the_full_error_of_a_pair_before_any_branch() { + let at = |z| Frame3 { + origin: [0.0, 0.0, z], + ..Frame3::IDENTITY + }; + let lower = primitives::sphere("lower".into(), at(0.3), 0.2, Accuracy::STANDARD).unwrap(); + let upper = primitives::sphere("upper".into(), at(0.6), 0.1, Accuracy::STANDARD).unwrap(); + let full = intersect_breps(&lower, &upper).err().unwrap(); + let until = intersect_breps_until_branch(&lower, &upper).err().unwrap(); + assert_eq!(until, full); + assert!(format!("{full:?}").contains("face pair 0:0"), "{full:?}"); +} + +#[test] +fn a_face_pair_query_and_both_body_graphs_still_refuse_an_invalid_body() { + let valid = primitives::cuboid( + "valid".into(), + Frame3::IDENTITY, + [1.0; 3], + Accuracy::STANDARD, + ) + .unwrap(); + let mut nameless = valid.clone(); + nameless.id = String::new(); + let refusal = nameless.validate().err().unwrap(); + let pair = intersect_faces( + FaceView { + brep: &nameless, + face: 0, + }, + FaceView { + brep: &valid, + face: 0, + }, + ); + assert_eq!(pair.err().unwrap(), refusal); + assert_eq!(intersect_breps(&valid, &nameless).err().unwrap(), refusal); + let until = intersect_breps_until_branch(&valid, &nameless); + assert_eq!(until.err().unwrap(), refusal); +} diff --git a/main/opengeometry/src/intersection/mod.rs b/main/opengeometry/src/intersection/mod.rs index 6624798c..1ac0a891 100644 --- a/main/opengeometry/src/intersection/mod.rs +++ b/main/opengeometry/src/intersection/mod.rs @@ -4,7 +4,8 @@ mod ssi_result; mod universal_ssi; pub(super) use face_intersection::{ - intersect_breps, intersect_faces, FaceView, IntersectionBranch, IntersectionGraph, + intersect_breps, intersect_breps_until_branch, intersect_faces, FaceView, IntersectionBranch, + IntersectionGraph, }; pub(super) use ssi::intersect_surfaces; diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/non_intersecting.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/non_intersecting.rs index 41eac5e4..e418bc52 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/generic/non_intersecting.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/generic/non_intersecting.rs @@ -1,5 +1,5 @@ use crate::brep::{BrepEnvelope, GeometryError}; -use crate::intersection::intersect_breps; +use crate::intersection::intersect_breps_until_branch; use crate::math::{norm, sub}; use crate::operations::modifying::boolean::handlers::containment::{ analytic_containment_boolean, interior_sample, @@ -14,7 +14,7 @@ pub(crate) fn generic_non_intersecting_boolean( operation: BooleanOp, id: String, ) -> Result, GeometryError> { - let graph = match intersect_breps(a, b) { + let graph = match intersect_breps_until_branch(a, b) { Ok(graph) => graph, Err(GeometryError::CoverageGap { .. }) => return Ok(None), Err(error) => return Err(error), diff --git a/main/opengeometry/src/query/face_trim.rs b/main/opengeometry/src/query/face_trim.rs index 91e5cfce..d5f2a249 100644 --- a/main/opengeometry/src/query/face_trim.rs +++ b/main/opengeometry/src/query/face_trim.rs @@ -3,53 +3,112 @@ use crate::brep::{ SurfaceGeometry, UV, }; use crate::math::{dot, norm}; +use std::borrow::Cow; + +pub(crate) struct FaceTrim { + band: Option<[Option; 2]>, + outer: Option>, + holes: Vec>>, +} + +impl FaceTrim { + pub(crate) fn new(brep: &BrepEnvelope, face: &Face) -> Result { + let surface = brep.geometry.surface(face.surface)?; + let band = has_band(surface, face) + .then(|| band_limits(brep, face).ok()) + .flatten(); + Ok(Self { + band, + outer: trim_samples(brep, face, face.trim.outer).ok(), + holes: face + .trim + .holes + .iter() + .map(|hole| trim_samples(brep, face, *hole).ok()) + .collect(), + }) + } + + pub(crate) fn contains( + &self, + brep: &BrepEnvelope, + face: &Face, + uv: UV, + ) -> Result, GeometryError> { + let surface = brep.geometry.surface(face.surface)?; + let uv = lift_to_face(surface, face, uv); + let jet = surface.derivatives(uv)?; + let metric = norm(jet.du).min(norm(jet.dv)); + let uv_tolerance = if metric > 0.0 { + brep.accuracy.geometric / metric + } else { + return Ok(None); + }; + if !face.trim.uv_bounds[0].contains(uv[0]) || !face.trim.uv_bounds[1].contains(uv[1]) { + return Ok(Some(false)); + } + if has_band(surface, face) { + let limits = match self.band { + Some(limits) => limits, + None => band_limits(brep, face)?, + }; + if let [Some(outer), Some(inner)] = limits { + let lo = outer.min(inner); + let hi = outer.max(inner); + if (uv[1] - lo).abs() <= uv_tolerance || (uv[1] - hi).abs() <= uv_tolerance { + return Ok(None); + } + return Ok(Some(uv[1] > lo && uv[1] < hi)); + } + } + let outer = polygon(&self.outer, brep, face, face.trim.outer)?; + let Some(mut inside) = in_loop(uv, &outer, uv_tolerance) else { + return Ok(None); + }; + if !inside { + return Ok(Some(false)); + } + for (stored, hole) in self.holes.iter().zip(&face.trim.holes) { + let polygon = polygon(stored, brep, face, *hole)?; + match in_loop(uv, &polygon, uv_tolerance) { + None => return Ok(None), + Some(true) => inside = false, + Some(false) => {} + } + } + Ok(Some(inside)) + } +} pub(crate) fn face_contains_uv( brep: &BrepEnvelope, face: &Face, uv: UV, ) -> Result, GeometryError> { - let surface = brep.geometry.surface(face.surface)?; - let uv = lift_to_face(surface, face, uv); - let jet = surface.derivatives(uv)?; - let metric = norm(jet.du).min(norm(jet.dv)); - let uv_tolerance = if metric > 0.0 { - brep.accuracy.geometric / metric - } else { - return Ok(None); - }; - if !face.trim.uv_bounds[0].contains(uv[0]) || !face.trim.uv_bounds[1].contains(uv[1]) { - return Ok(Some(false)); - } - if matches!(surface, SurfaceGeometry::Cylinder { .. }) && face.trim.holes.len() == 1 { - if let (Some(outer), Some(inner)) = ( - periodic_cylinder_band_limit(brep, face, face.trim.outer)?, - periodic_cylinder_band_limit(brep, face, face.trim.holes[0])?, - ) { - let lo = outer.min(inner); - let hi = outer.max(inner); - if (uv[1] - lo).abs() <= uv_tolerance || (uv[1] - hi).abs() <= uv_tolerance { - return Ok(None); - } - return Ok(Some(uv[1] > lo && uv[1] < hi)); - } - } - let outer = trim_samples(brep, face, face.trim.outer)?; - let Some(mut inside) = in_loop(uv, &outer, uv_tolerance) else { - return Ok(None); - }; - if !inside { - return Ok(Some(false)); - } - for hole in &face.trim.holes { - let polygon = trim_samples(brep, face, *hole)?; - match in_loop(uv, &polygon, uv_tolerance) { - None => return Ok(None), - Some(true) => inside = false, - Some(false) => {} - } + FaceTrim::new(brep, face)?.contains(brep, face, uv) +} + +fn has_band(surface: &SurfaceGeometry, face: &Face) -> bool { + matches!(surface, SurfaceGeometry::Cylinder { .. }) && face.trim.holes.len() == 1 +} + +fn band_limits(brep: &BrepEnvelope, face: &Face) -> Result<[Option; 2], GeometryError> { + Ok([ + periodic_cylinder_band_limit(brep, face, face.trim.outer)?, + periodic_cylinder_band_limit(brep, face, face.trim.holes[0])?, + ]) +} + +fn polygon<'a>( + stored: &'a Option>, + brep: &BrepEnvelope, + face: &Face, + loop_id: u32, +) -> Result, GeometryError> { + match stored { + Some(points) => Ok(Cow::Borrowed(points)), + None => trim_samples(brep, face, loop_id).map(Cow::Owned), } - Ok(Some(inside)) } fn lift_to_face(surface: &SurfaceGeometry, face: &Face, mut uv: UV) -> UV { diff --git a/main/opengeometry/src/query/mod.rs b/main/opengeometry/src/query/mod.rs index 88746abc..a435f890 100644 --- a/main/opengeometry/src/query/mod.rs +++ b/main/opengeometry/src/query/mod.rs @@ -9,5 +9,5 @@ mod tests; pub use classification::PointClassification; pub use classify::classify_point; pub(super) use classify::classify_point_in_shell; -pub(super) use face_trim::face_contains_uv; +pub(super) use face_trim::{face_contains_uv, FaceTrim}; pub use measure::{validity, Validity}; diff --git a/main/opengeometry/src/query/tests.rs b/main/opengeometry/src/query/tests.rs index a1330868..33a0cabc 100644 --- a/main/opengeometry/src/query/tests.rs +++ b/main/opengeometry/src/query/tests.rs @@ -1,8 +1,14 @@ use super::classification::PointClassification; use super::classify::{classify_point, classify_point_validated}; -use crate::brep::{coarse_accuracy, unit, Accuracy, BrepEnvelope, Frame3}; +use super::face_trim::{face_contains_uv, FaceTrim}; +use crate::brep::{ + coarse_accuracy, unit, Accuracy, BrepEnvelope, Frame3, SurfaceGeometry, UVBox, UV, +}; use crate::math::{add, cross, dot, scale, Point3}; -use crate::primitives; +use crate::operations::modifying::boolean::{boolean_brep, BooleanOp}; +use crate::primitives::{self, ProfileEdge}; +use sha2::{Digest, Sha256}; +use std::f64::consts::PI; const STEP: f64 = 10.0 * Accuracy::STANDARD.geometric; const SURFACE: [PointClassification; 3] = [ @@ -431,3 +437,138 @@ fn cuboid_corners_edges_and_face_points_are_boundary() { .collect(), ); } + +fn crossing_grid(bounds: UVBox) -> Vec { + let steps = 24; + let at = move |axis: usize, index: usize| { + let interval = bounds[axis]; + interval.lo + interval.width() * (1.5 * index as f64 / steps as f64 - 0.25) + }; + (0..=steps) + .flat_map(|u| (0..=steps).map(move |v| [at(0, u), at(1, v)])) + .collect() +} + +fn tube() -> BrepEnvelope { + let rod = Frame3 { + origin: [0.0, 0.0, -0.5], + ..Frame3::IDENTITY + }; + let body = primitives::cylinder( + "body".into(), + Frame3::IDENTITY, + 0.3, + 1.0, + Accuracy::STANDARD, + ); + let bore = primitives::cylinder("bore".into(), rod, 0.1, 2.0, Accuracy::STANDARD); + boolean_brep( + &body.unwrap(), + &bore.unwrap(), + BooleanOp::Subtraction, + "tube".into(), + ) + .unwrap() + .brep +} + +fn holed_d() -> BrepEnvelope { + let half_turn = |start_angle| ProfileEdge::Arc { + center: [0.2, 0.2], + radius: 0.1, + start_angle, + sweep_angle: PI, + }; + let outer = vec![ + ProfileEdge::Line { + from: [0.0, 0.0], + to: [0.4, 0.0], + }, + ProfileEdge::Arc { + center: [0.4, 0.2], + radius: 0.2, + start_angle: -PI / 2.0, + sweep_angle: PI, + }, + ProfileEdge::Line { + from: [0.4, 0.4], + to: [0.0, 0.4], + }, + ProfileEdge::Line { + from: [0.0, 0.4], + to: [0.0, 0.0], + }, + ]; + let hole = vec![half_turn(0.0), half_turn(PI)]; + primitives::arc_edged_extrusion_with_holes( + "holed".into(), + Frame3::IDENTITY, + outer, + vec![hole], + 0.3, + Accuracy::STANDARD, + ) + .unwrap() +} + +fn probed_bodies() -> [BrepEnvelope; 2] { + [holed_d(), tube()] +} + +fn stored_compared() -> bool { + std::env::var("CI").as_deref() != Ok("true") +} + +fn answer_record(answers: &[Option]) -> ([usize; 3], String) { + let codes = answers + .iter() + .map(|answer| answer.map_or(0, |inside| 1 + u8::from(inside))) + .collect::>(); + let counts = [0, 1, 2].map(|code| codes.iter().filter(|value| **value == code).count()); + (counts, format!("{:x}", Sha256::digest(&codes))) +} + +const RECORDED: [([usize; 3], &str); 2] = [ + ( + [417, 2863, 1720], + "22153794c07f0549c60da52fa4442fef30cb55dfcee3bd6a3168f36f62ed78de", + ), + ( + [138, 1298, 1064], + "1b5f23003c8b925162d8f2aed4ad0206f30c7f4956a727dfc9e779bb71c98d2d", + ), +]; + +#[test] +fn one_face_trim_reused_for_every_probe_gives_the_recorded_answers_on_a_holed_d_and_a_tube() { + let bodies = probed_bodies(); + for (body, (counts, digest)) in bodies.iter().zip(RECORDED) { + let mut answers = Vec::new(); + for face in &body.topology.faces { + let trim = FaceTrim::new(body, face).unwrap(); + for uv in crossing_grid(face.trim.uv_bounds) { + let reused = trim.contains(body, face, uv).unwrap(); + let fresh = face_contains_uv(body, face, uv).unwrap(); + assert_eq!(reused, fresh, "{} face {} at {uv:?}", body.id, face.id); + answers.push(reused); + } + } + let (measured, sequence) = answer_record(&answers); + if stored_compared() { + assert_eq!(measured, counts, "{}", body.id); + assert_eq!(sequence, digest, "{}", body.id); + } + } + let faces = || { + bodies + .iter() + .flat_map(|body| body.topology.faces.iter().map(move |face| (body, face))) + }; + assert!(faces().any(|(_, face)| !face.trim.holes.is_empty())); + assert!(faces().any(|(body, face)| { + matches!( + body.geometry.surfaces[face.surface as usize], + SurfaceGeometry::Cylinder { .. } + ) && face.trim.holes.len() == 1 + })); +} diff --git a/main/opengeometry/test-support/src/scenes/mod.rs b/main/opengeometry/test-support/src/scenes/mod.rs index aece6107..a00f75a2 100644 --- a/main/opengeometry/test-support/src/scenes/mod.rs +++ b/main/opengeometry/test-support/src/scenes/mod.rs @@ -3,3 +3,4 @@ pub mod coverage_gap; pub mod prismatic; pub mod revolve; pub mod storey; +pub mod wc; diff --git a/main/opengeometry/test-support/src/scenes/wc.rs b/main/opengeometry/test-support/src/scenes/wc.rs new file mode 100644 index 00000000..c3b4208d --- /dev/null +++ b/main/opengeometry/test-support/src/scenes/wc.rs @@ -0,0 +1,118 @@ +use opengeometry::brep::{Accuracy, BrepEnvelope, Frame3}; +use opengeometry::operations::modifying::boolean::shell_brep; +use opengeometry::primitives::{arc_edged_extrusion, ProfileEdge}; +use std::f64::consts::TAU; + +pub const PAN_VOLUME: f64 = 0.010544; + +pub const SEAT_VOLUME: f64 = 0.0017897; + +fn ground(point: [f64; 2]) -> [f64; 2] { + [point[0], -point[1]] +} + +fn line(from: [f64; 2], to: [f64; 2]) -> ProfileEdge { + ProfileEdge::Line { + from: ground(from), + to: ground(to), + } +} + +fn arc(from: [f64; 2], to: [f64; 2], center: [f64; 2], counter_clockwise: bool) -> ProfileEdge { + let [from, to, center] = [from, to, center].map(ground); + let radius = (from[0] - center[0]).hypot(from[1] - center[1]); + let start_angle = (from[1] - center[1]).atan2(from[0] - center[0]); + let mut sweep_angle = (to[1] - center[1]).atan2(to[0] - center[0]) - start_angle; + if counter_clockwise && sweep_angle <= 1e-12 { + sweep_angle += TAU; + } else if !counter_clockwise && sweep_angle >= -1e-12 { + sweep_angle -= TAU; + } + ProfileEdge::Arc { + center, + radius, + start_angle, + sweep_angle, + } +} + +fn extrusion(id: &str, base: f64, top: f64, profile: Vec) -> BrepEnvelope { + let frame = Frame3 { + origin: [0.0, 0.0, base], + ..Frame3::IDENTITY + }; + arc_edged_extrusion(id.into(), frame, profile, top - base, Accuracy::STANDARD).unwrap() +} + +pub fn pan_shell() -> BrepEnvelope { + let rim = [0.1053574007, 0.2624440433]; + let profile = vec![ + line([-0.185, -0.325], [-0.185, -0.05]), + arc( + [-0.185, -0.05], + [-rim[0], rim[1]], + [0.4676923077, -0.05], + true, + ), + arc([-rim[0], rim[1]], rim, [0.0, 0.205], true), + arc(rim, [0.185, -0.05], [-0.4676923077, -0.05], true), + line([0.185, -0.05], [0.185, -0.325]), + line([0.185, -0.325], [-0.185, -0.325]), + ]; + let solid = extrusion("panSolid", 0.18, 0.4, profile); + shell_brep(&solid, 0.015, "panShell".into()).unwrap().brep +} + +pub fn bowl_opening() -> BrepEnvelope { + let points = [ + [0.12, 0.08], + [0.054, 0.212], + [-0.054, 0.212], + [-0.12, 0.08], + [-0.054, -0.052], + [0.054, -0.052], + ]; + let centers = [ + [-0.045, 0.08], + [0.0, 0.14], + [0.045, 0.08], + [0.045, 0.08], + [0.0, 0.02], + [-0.045, 0.08], + ]; + let profile = (0..6) + .map(|index| { + arc( + points[index], + points[(index + 1) % 6], + centers[index], + false, + ) + }) + .collect(); + extrusion("bowlOpening", 0.35, 0.41, profile) +} + +pub fn seat_solid() -> BrepEnvelope { + let rim = [0.1053574007, 0.2624440433]; + let profile = vec![ + line([-0.185, -0.12], [0.185, -0.12]), + line([0.185, -0.12], [0.185, -0.05]), + arc([0.185, -0.05], rim, [-0.4676923077, -0.05], false), + arc(rim, [-rim[0], rim[1]], [0.0, 0.205], false), + arc( + [-rim[0], rim[1]], + [-0.185, -0.05], + [0.4676923077, -0.05], + false, + ), + line([-0.185, -0.05], [-0.185, -0.12]), + ]; + extrusion("seatSolid", 0.4, 0.42, profile) +} + +pub fn seat_hole() -> BrepEnvelope { + let (low, high, center) = ([0.0, -0.04], [0.0, 0.2], [0.0, 0.08]); + let profile = vec![arc(low, high, center, false), arc(high, low, center, false)]; + extrusion("seatHole", 0.39, 0.43, profile) +} diff --git a/main/opengeometry/tests/booleans/main.rs b/main/opengeometry/tests/booleans/main.rs index c0cac6cd..113ad92a 100644 --- a/main/opengeometry/tests/booleans/main.rs +++ b/main/opengeometry/tests/booleans/main.rs @@ -14,3 +14,4 @@ mod prismatic_placed; mod routing; mod stair_waist; mod support; +mod wc_subtract; diff --git a/main/opengeometry/tests/booleans/wc_subtract.rs b/main/opengeometry/tests/booleans/wc_subtract.rs new file mode 100644 index 00000000..d225979d --- /dev/null +++ b/main/opengeometry/tests/booleans/wc_subtract.rs @@ -0,0 +1,83 @@ +use crate::support::{assert_valid_closed, assert_volume}; +use opengeometry::brep::BrepEnvelope; +use opengeometry::operations::modifying::boolean::{multi_tool_boolean, BooleanOp}; +use opengeometry_test_support::scenes::wc; +use opengeometry_test_support::stored; +use opengeometry_test_support::volume; +use sha2::{Digest, Sha256}; + +const SPECIALIZED: [&str; 4] = [ + "separate_boolean", + "boolean_spheres", + "subtract_vertical_arc_extrusion", + "generic_two_sided_cutter_band_subtraction", +]; + +struct Expected { + handler: &'static str, + attempted: Vec<&'static str>, + counts: [usize; 3], + volume: f64, + sha256: &'static str, +} + +fn assert_subtract(host: BrepEnvelope, tool: BrepEnvelope, expected: Expected) { + let outcome = multi_tool_boolean(&host, &[tool], BooleanOp::Subtraction, "wc".into()); + let label = expected.handler; + assert_eq!(outcome.handlers, [expected.handler], "{label}"); + assert_eq!(outcome.attempted, expected.attempted, "{label}"); + let body = outcome.result.unwrap().brep; + assert_valid_closed(&body, 1, label); + let topology = &body.topology; + assert_eq!( + [ + topology.faces.len(), + topology.edges.len(), + topology.vertices.len() + ], + expected.counts, + "{label}" + ); + assert_volume(volume::estimate(&body, 1e-5), expected.volume, label); + if stored::compared() { + let digest = Sha256::digest(body.to_json().unwrap().as_bytes()); + assert_eq!(format!("{digest:x}"), expected.sha256, "{label}"); + } +} + +#[test] +fn the_wc_pan_shell_minus_its_six_arc_bowl_opening_is_the_recorded_prismatic_result() { + let mut attempted = SPECIALIZED.to_vec(); + attempted.extend([ + "generic_multiple_closed_loops_boolean", + "generic_single_closed_loop_boolean", + "generic_non_intersecting_boolean", + "prismatic_curved_boolean", + ]); + assert_subtract( + wc::pan_shell(), + wc::bowl_opening(), + Expected { + handler: "prismatic_curved_boolean", + attempted, + counts: [22, 78, 60], + volume: wc::PAN_VOLUME, + sha256: "1dae88747896c1005a22f519077e3e3abf69fd5d015f1be46724fe28c7351f2a", + }, + ); +} + +#[test] +fn the_wc_seat_minus_its_round_hole_is_the_recorded_band_result() { + assert_subtract( + wc::seat_solid(), + wc::seat_hole(), + Expected { + handler: "generic_two_sided_cutter_band_subtraction", + attempted: SPECIALIZED.to_vec(), + counts: [10, 24, 16], + volume: wc::SEAT_VOLUME, + sha256: "ab0f88eefe4796a22faf23d55010a033f008c823f931f54093165550c18c3e58", + }, + ); +} From 9c63f52c205e7885b8e1a13694cf9c60a9ad1074 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 3 Nov 2026 22:38:39 +0100 Subject: [PATCH 2/3] Add OpenGeometry.releaseIdleGeometry to dispose idle display records The record pool keeps up to two unreferenced display records per shape so a level-of-detail switch back is instant. A renderer that drew those records leaves its dispose listener on each one, so after an app disposes a renderer, its buffer state stays reachable through the idle records until they are evicted. The new call disposes every idle record and reports how many records and bytes it released, so an app that replaces its renderer can drop that state at once. Records in use by displayed bodies are kept. --- README.md | 2 +- main/opengeometry-three/src/opengeometry.ts | 2 + .../src/rendering/records/record-pool.ts | 7 ++ .../tests/browser/pages/release-idle.html | 12 +++ .../tests/browser/pages/release-idle.ts | 87 +++++++++++++++ .../tests/browser/specs/release-idle.spec.ts | 18 ++++ .../tests/browser/support/fixture-types.ts | 12 +++ .../tests/browser/support/test-page.ts | 2 +- .../tests/node/release-idle.test.mjs | 101 ++++++++++++++++++ 9 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 main/opengeometry-three/tests/browser/pages/release-idle.html create mode 100644 main/opengeometry-three/tests/browser/pages/release-idle.ts create mode 100644 main/opengeometry-three/tests/browser/specs/release-idle.spec.ts create mode 100644 main/opengeometry-three/tests/node/release-idle.test.mjs diff --git a/README.md b/README.md index 6ea43d52..a7128bc0 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o | `SystemAssembly` | `new SystemAssembly({ ogId?, parent? })`. `addChild(children, { keepWorld? })`, `removeChild(child, { keepWorld? })`, `getChildren()`, `getParent()`, `getBounds()`, `transform`, `dispose()`. Bodies have the same tree methods. | | Queries | `getBounds()` (`[minX, minY, minZ, maxX, maxY, maxZ]`, or `null`), `getBrep()`, `getWorldBrep()` (the BRep placed in the world), `faceNormalAt(faceId, [x, y, z])` (the face's unit normal in the body's own coordinates), `classifyPoint([x, y, z])` (`'Inside'`, `'Outside'`, `'Boundary'` or `'Unknown'`, in the body's own coordinates), `measure({ deflection? })` (`{ volume, area, errorBound, areaErrorBound, deflection }`; the volume is within `errorBound` of the exact one, the area within `areaErrorBound`), `validity()` (`{ valid, closed, bodyType, error? }`), `solid.getReport()` (the last boolean's report, or `null`). | | Appearance | `appearance` in the options, or `body.setAppearance({ color?, opacity?, outline?, pickOutline?, deflection? })`. | -| Display | `OpenGeometry.settled({ deflection? })` resolves to `{ failed }`; `OpenGeometry.flush({ geometry?: 'sync', bodies? })` (with `bodies`, meshes and places only those bodies now; hidden bodies, bodies with no shape and bodies in limbo (disposed while a mark is live) are skipped; a fully disposed body or one from a reset runtime throws `Disposed` before anything is meshed; `bodies` without `geometry: 'sync'`, a non-array `bodies` or an item that is not a Wire, Sheet or Solid throws `InvalidParameter`); `OpenGeometry.setDisplayDeflection(world?)`; `OpenGeometry.setCameraMotion(moving)`. | +| Display | `OpenGeometry.settled({ deflection? })` resolves to `{ failed }`; `OpenGeometry.flush({ geometry?: 'sync', bodies? })` (with `bodies`, meshes and places only those bodies now; hidden bodies, bodies with no shape and bodies in limbo (disposed while a mark is live) are skipped; a fully disposed body or one from a reset runtime throws `Disposed` before anything is meshed; `bodies` without `geometry: 'sync'`, a non-array `bodies` or an item that is not a Wire, Sheet or Solid throws `InvalidParameter`); `OpenGeometry.setDisplayDeflection(world?)`; `OpenGeometry.setCameraMotion(moving)`; `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`. | | Picking | `OpenGeometry.resolveHit(intersection)` takes a Three.js raycast hit and returns `{ ogId, shapeRevision, faceId?, edgeId? }` or `undefined`. | | `OGMark` | `const mark = OpenGeometry.mark()`, then `mark.rollback()` and `mark.release()`. `OpenGeometry.markStats()` returns `{ liveMarks, retainedRevisions }`. `OpenGeometry.transaction(fn, { dryRun? })` runs a synchronous `fn` and rolls back if it throws. | | Events | `OpenGeometry.on(event, handler)` with `'geometry'`, `'warning'`, `'error'` or `'fatal'`; it returns a function that unsubscribes. | diff --git a/main/opengeometry-three/src/opengeometry.ts b/main/opengeometry-three/src/opengeometry.ts index 4a24ec85..84a63610 100644 --- a/main/opengeometry-three/src/opengeometry.ts +++ b/main/opengeometry-three/src/opengeometry.ts @@ -48,6 +48,8 @@ export class OpenGeometry { overrideChanged(runtime()); } + static releaseIdleGeometry(): { records: number; bytes: number } { return runtime().records.releaseIdle(); } + static setCameraMotion(moving: boolean): void { const state = runtime(); if (moving) motionHint(state, Date.now()); diff --git a/main/opengeometry-three/src/rendering/records/record-pool.ts b/main/opengeometry-three/src/rendering/records/record-pool.ts index b34b6e17..1e9fa889 100644 --- a/main/opengeometry-three/src/rendering/records/record-pool.ts +++ b/main/opengeometry-three/src/rendering/records/record-pool.ts @@ -64,6 +64,13 @@ export class RecordPool { } } + releaseIdle(): { records: number; bytes: number } { + const idle = [...this.idle]; + const bytes = this.idleBytes; + for (const record of idle) this.drop(record); + return { records: idle.length, bytes }; + } + placeholder(shapeId: string, revision: number, bounds: () => Bounds | null): THREE.BufferGeometry { const key = `${shapeId}@${String(revision)}`; let entry = this.placeholders.get(key); diff --git a/main/opengeometry-three/tests/browser/pages/release-idle.html b/main/opengeometry-three/tests/browser/pages/release-idle.html new file mode 100644 index 00000000..41274ef8 --- /dev/null +++ b/main/opengeometry-three/tests/browser/pages/release-idle.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry idle release test + + +
Loading kernel
+ + + diff --git a/main/opengeometry-three/tests/browser/pages/release-idle.ts b/main/opengeometry-three/tests/browser/pages/release-idle.ts new file mode 100644 index 00000000..7e9adc2a --- /dev/null +++ b/main/opengeometry-three/tests/browser/pages/release-idle.ts @@ -0,0 +1,87 @@ +import * as THREE from 'three'; +import { OpenGeometry, Solid, OG_PRIMITIVE_CUBOID, OG_TRANSFORM_TRANSLATE } from '../../../../dist/index.js'; +import { runtime } from '../../../../dist/testing.js'; +import type { ReleaseIdleResult } from '../support/fixture-types'; +import { bootKernel, createRenderer, publishFixture } from '../support/test-page'; + +type PooledRecord = { bytes: number; surface: THREE.BufferGeometry | null; outline: THREE.BufferGeometry }; + +function isPooled(value: unknown): value is PooledRecord { + return typeof value === 'object' && value !== null && 'bytes' in value && 'outline' in value; +} + +function idleRecords(): PooledRecord[] { + const idle: unknown = Reflect.get(runtime().records, 'idle'); + if (!(idle instanceof Set)) throw new Error('record pool has no idle set'); + return [...idle].filter(isPooled); +} + +function disposeListeners(geometry: THREE.BufferGeometry | null): number { + const listeners: unknown = geometry ? Reflect.get(geometry, '_listeners') : undefined; + const dispose: unknown = listeners ? Reflect.get(listeners, 'dispose') : undefined; + return Array.isArray(dispose) ? dispose.length : 0; +} + +function withListeners(records: PooledRecord[]): number { + return records.filter((record) => disposeListeners(record.surface) + disposeListeners(record.outline) > 0).length; +} + +function sceneOf(count: number): THREE.Scene { + const scene = new THREE.Scene(); + scene.add(new THREE.HemisphereLight(0xffffff, 0x526070, 2)); + for (let index = 0; index < count; index++) { + const body = new Solid(OG_PRIMITIVE_CUBOID, { width: 1 + index / 100, height: 1, depth: 1 }); + body.transform(OG_TRANSFORM_TRANSLATE, { offset: [(index % 5) * 2 - 4, 0, Math.floor(index / 5) * 2 - 4] }); + scene.add(body); + } + return scene; +} + +function disposeReachable(scene: THREE.Scene): void { + const owned = new Set(); + scene.traverse((object) => { + const geometry: unknown = Reflect.get(object, 'geometry'); + if (geometry instanceof THREE.BufferGeometry) owned.add(geometry); + const materials: unknown = Reflect.get(object, 'material'); + for (const material of [materials].flat()) { + if (!(material instanceof THREE.Material)) continue; + owned.add(material); + for (const value of Object.values(material)) if (value instanceof THREE.Texture) owned.add(value); + } + }); + for (const item of owned) item.dispatchEvent({ type: 'dispose' }); +} + +async function drawnIdleRecords(scene: THREE.Scene, camera: THREE.Camera): Promise { + const renderer = createRenderer(320, 240); + for (const deflection of [undefined, 0.5, 0.002]) { + OpenGeometry.setDisplayDeflection(deflection); + renderer.render(scene, camera); + await OpenGeometry.settled(); + renderer.render(scene, camera); + } + disposeReachable(scene); + renderer.dispose(); + renderer.forceContextLoss(); + renderer.domElement.remove(); +} + +async function releaseIdleProbe(): Promise { + const scene = sceneOf(20); + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 0.1, 100); + camera.position.set(6, 8, 12); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(); + await drawnIdleRecords(scene, camera); + const idle = idleRecords(); + const idleBytes = idle.reduce((sum, record) => sum + record.bytes, 0); + const idleWithListeners = withListeners(idle); + const released = OpenGeometry.releaseIdleGeometry(); + return { + idle: idle.length, idleBytes, idleWithListeners, released, idleAfter: idleRecords().length, + releasedWithListeners: withListeners(idle), + }; +} + +await bootKernel(); +publishFixture('ogReleaseIdle', { releaseIdleProbe }, 'Release idle ready'); diff --git a/main/opengeometry-three/tests/browser/specs/release-idle.spec.ts b/main/opengeometry-three/tests/browser/specs/release-idle.spec.ts new file mode 100644 index 00000000..803440b8 --- /dev/null +++ b/main/opengeometry-three/tests/browser/specs/release-idle.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; +import type { ReleaseIdleResult } from '../support/fixture-types'; + +test('a released pool keeps no dispose listener of a disposed renderer on idle records', async ({ page }) => { + test.setTimeout(45_000); + await page.goto('/release-idle.html?backend=inline'); + await expect(page.locator('#status')).toHaveText('Release idle ready', { timeout: 20_000 }); + const result = await page.evaluate(async (): Promise => { + const fixture = window.ogReleaseIdle; + if (!fixture) throw new Error('The release idle page published no probe'); + return await fixture.releaseIdleProbe(); + }); + expect(result.idle).toBeGreaterThan(0); + expect(result.idleWithListeners).toBe(result.idle); + expect(result.released).toEqual({ records: result.idle, bytes: result.idleBytes }); + expect(result.idleAfter).toBe(0); + expect(result.releasedWithListeners).toBe(0); +}); diff --git a/main/opengeometry-three/tests/browser/support/fixture-types.ts b/main/opengeometry-three/tests/browser/support/fixture-types.ts index 788af5a6..c52ff6d4 100644 --- a/main/opengeometry-three/tests/browser/support/fixture-types.ts +++ b/main/opengeometry-three/tests/browser/support/fixture-types.ts @@ -188,8 +188,20 @@ export type QuickStartFixture = { dispose(): void; }; +export type ReleaseIdleResult = { + idle: number; + idleBytes: number; + idleWithListeners: number; + released: { records: number; bytes: number }; + idleAfter: number; + releasedWithListeners: number; +}; + +export type ReleaseIdleFixture = { releaseIdleProbe(): Promise }; + declare global { var ogSmoke: SmokeFixture | undefined; var ogAcceptance: AcceptanceFixture | undefined; var ogQuickStart: QuickStartFixture | undefined; + var ogReleaseIdle: ReleaseIdleFixture | undefined; } diff --git a/main/opengeometry-three/tests/browser/support/test-page.ts b/main/opengeometry-three/tests/browser/support/test-page.ts index ac0d84cf..3286f78d 100644 --- a/main/opengeometry-three/tests/browser/support/test-page.ts +++ b/main/opengeometry-three/tests/browser/support/test-page.ts @@ -49,7 +49,7 @@ export function statusElement(): Element { return status; } -export function publishFixture( +export function publishFixture( key: K, fixture: NonNullable<(typeof globalThis)[K]>, status: string, ): void { globalThis[key] = fixture; diff --git a/main/opengeometry-three/tests/node/release-idle.test.mjs b/main/opengeometry-three/tests/node/release-idle.test.mjs new file mode 100644 index 00000000..82b38e1e --- /dev/null +++ b/main/opengeometry-three/tests/node/release-idle.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import * as THREE from 'three'; +import { OpenGeometry } from '../../../dist/index.js'; +import * as TESTING from '../../../dist/testing.js'; +import { boot, cuboid } from './support.mjs'; + +async function fresh() { + await boot(); + OpenGeometry.reset(); +} + +function pool() { + return Object(TESTING.runtime().records); +} + +function idleRecords() { + return [...Reflect.get(pool(), 'idle')]; +} + +function disposals(records) { + const disposed = new Set(); + for (const record of records) { + record.outline.addEventListener('dispose', () => { disposed.add(record); }); + } + return disposed; +} + +async function shownWithIdle(ogId) { + const body = cuboid(ogId); + const scene = new THREE.Scene(); + scene.add(body); + scene.updateMatrixWorld(true); + await OpenGeometry.settled(); + const coarse = body.record; + body.setAppearance({ deflection: 2 ** -12 }); + await OpenGeometry.settled(); + return { body, coarse }; +} + +test('idle records are disposed and their count and bytes returned', async () => { + await fresh(); + const { body, coarse } = await shownWithIdle('idle-cube'); + try { + const idle = idleRecords(); + assert.deepEqual(idle, [coarse]); + const disposed = disposals(idle); + const bytes = Reflect.get(pool(), 'idleBytes'); + assert.equal(bytes, coarse?.bytes); + assert.deepEqual(OpenGeometry.releaseIdleGeometry(), { records: 1, bytes }); + assert.deepEqual([...disposed], idle); + assert.deepEqual(idleRecords(), []); + assert.equal(Reflect.get(pool(), 'idleBytes'), 0); + assert.deepEqual(OpenGeometry.releaseIdleGeometry(), { records: 0, bytes: 0 }); + } finally { + body.dispose(); + } +}); + +test('installed records survive a release', async () => { + await fresh(); + const { body } = await shownWithIdle('installed-cube'); + try { + const installed = body.record; + if (!installed) throw new Error('no installed record'); + const disposed = disposals([installed]); + OpenGeometry.releaseIdleGeometry(); + assert.equal(body.record, installed); + assert.equal(disposed.size, 0); + assert.equal(pool().get(installed.shapeId, installed.revision, installed.bucket), installed); + } finally { + body.dispose(); + } +}); + +test('a later ensureGeometry re-requests a released bucket', async () => { + await fresh(); + const { body, coarse } = await shownWithIdle('rerequest-cube'); + const provider = Object(TESTING.runtime().provider); + const compute = provider.compute.bind(provider); + const requests = []; + provider.compute = (request) => { requests.push(request.bucket); return compute(request); }; + try { + OpenGeometry.releaseIdleGeometry(); + body.setAppearance({ deflection: coarse?.bucket ?? 1 }); + TESTING.ensureGeometry(body, true); + assert.deepEqual(requests, [coarse?.bucket]); + assert.notEqual(body.record, coarse); + assert.equal(body.record?.bucket, coarse?.bucket); + } finally { + delete provider.compute; + body.dispose(); + } +}); + +test('a release after a reset returns nothing released', async () => { + await fresh(); + await shownWithIdle('reset-cube'); + OpenGeometry.reset(); + assert.deepEqual(OpenGeometry.releaseIdleGeometry(), { records: 0, bytes: 0 }); +}); From 32f956c3e9a90da4d99ef0307a9ccc9877ee732f Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 3 Nov 2026 23:03:11 +0100 Subject: [PATCH 3/3] Skip the exact trim edge distance for segments clearly out of reach After the face trims were cached, the WC pan subtract still spent about half its time in the point-in-trim loop, measuring every probe's exact distance to every trim segment through the software fused multiply-add and hypot of the wasm build. A segment whose bounding box, widened by twice the tolerance plus a rounding margin, does not reach the probe cannot be within tolerance of it, so its exact distance is skipped; the crossing count still visits every segment and non-finite values take the exact path. Every answer is unchanged, and a test pins the boundary band on both sides of a trim edge and corner. --- main/opengeometry/src/query/face_trim.rs | 16 +- .../opengeometry/src/query/tests/face_trim.rs | 189 ++++++++++++++++++ .../src/query/{tests.rs => tests/mod.rs} | 147 +------------- 3 files changed, 208 insertions(+), 144 deletions(-) create mode 100644 main/opengeometry/src/query/tests/face_trim.rs rename main/opengeometry/src/query/{tests.rs => tests/mod.rs} (75%) diff --git a/main/opengeometry/src/query/face_trim.rs b/main/opengeometry/src/query/face_trim.rs index d5f2a249..bd7383a3 100644 --- a/main/opengeometry/src/query/face_trim.rs +++ b/main/opengeometry/src/query/face_trim.rs @@ -237,7 +237,9 @@ fn in_loop(point: UV, polygon: &[UV], tolerance: f64) -> Option { for index in 0..polygon.len() { let a = polygon[index]; let b = polygon[(index + 1) % polygon.len()]; - if segment_distance_2d(point, a, b) <= tolerance { + if !clear_of_segment(point, a, b, tolerance) + && segment_distance_2d(point, a, b) <= tolerance + { return None; } if (a[1] > point[1]) != (b[1] > point[1]) { @@ -250,6 +252,18 @@ fn in_loop(point: UV, polygon: &[UV], tolerance: f64) -> Option { Some(inside) } +fn clear_of_segment(point: UV, a: UV, b: UV, tolerance: f64) -> bool { + let scale = [point, a, b] + .iter() + .flatten() + .fold(0.0_f64, |largest, value| largest.max(value.abs())); + let reach = 2.0 * tolerance + 1e-14 * scale; + (0..2).any(|axis| { + let gap = (a[axis].min(b[axis]) - point[axis]).max(point[axis] - a[axis].max(b[axis])); + gap > reach + }) +} + fn segment_distance_2d(point: UV, a: UV, b: UV) -> f64 { let direction = [b[0] - a[0], b[1] - a[1]]; let length_squared = dot( diff --git a/main/opengeometry/src/query/tests/face_trim.rs b/main/opengeometry/src/query/tests/face_trim.rs new file mode 100644 index 00000000..37ce0d8e --- /dev/null +++ b/main/opengeometry/src/query/tests/face_trim.rs @@ -0,0 +1,189 @@ +use super::super::face_trim::{face_contains_uv, FaceTrim}; +use crate::brep::{Accuracy, BrepEnvelope, Frame3, Surface, SurfaceGeometry, UVBox, UV}; +use crate::math::norm; +use crate::operations::modifying::boolean::{boolean_brep, BooleanOp}; +use crate::primitives::{self, ProfileEdge}; +use sha2::{Digest, Sha256}; +use std::f64::consts::PI; + +fn crossing_grid(bounds: UVBox) -> Vec { + let steps = 24; + let at = move |axis: usize, index: usize| { + let interval = bounds[axis]; + interval.lo + interval.width() * (1.5 * index as f64 / steps as f64 - 0.25) + }; + (0..=steps) + .flat_map(|u| (0..=steps).map(move |v| [at(0, u), at(1, v)])) + .collect() +} + +fn tube() -> BrepEnvelope { + let rod = Frame3 { + origin: [0.0, 0.0, -0.5], + ..Frame3::IDENTITY + }; + let body = primitives::cylinder( + "body".into(), + Frame3::IDENTITY, + 0.3, + 1.0, + Accuracy::STANDARD, + ); + let bore = primitives::cylinder("bore".into(), rod, 0.1, 2.0, Accuracy::STANDARD); + boolean_brep( + &body.unwrap(), + &bore.unwrap(), + BooleanOp::Subtraction, + "tube".into(), + ) + .unwrap() + .brep +} + +fn holed_d() -> BrepEnvelope { + let half_turn = |start_angle| ProfileEdge::Arc { + center: [0.2, 0.2], + radius: 0.1, + start_angle, + sweep_angle: PI, + }; + let outer = vec![ + ProfileEdge::Line { + from: [0.0, 0.0], + to: [0.4, 0.0], + }, + ProfileEdge::Arc { + center: [0.4, 0.2], + radius: 0.2, + start_angle: -PI / 2.0, + sweep_angle: PI, + }, + ProfileEdge::Line { + from: [0.4, 0.4], + to: [0.0, 0.4], + }, + ProfileEdge::Line { + from: [0.0, 0.4], + to: [0.0, 0.0], + }, + ]; + let hole = vec![half_turn(0.0), half_turn(PI)]; + primitives::arc_edged_extrusion_with_holes( + "holed".into(), + Frame3::IDENTITY, + outer, + vec![hole], + 0.3, + Accuracy::STANDARD, + ) + .unwrap() +} + +fn probed_bodies() -> [BrepEnvelope; 2] { + [holed_d(), tube()] +} + +fn stored_compared() -> bool { + std::env::var("CI").as_deref() != Ok("true") +} + +fn answer_record(answers: &[Option]) -> ([usize; 3], String) { + let codes = answers + .iter() + .map(|answer| answer.map_or(0, |inside| 1 + u8::from(inside))) + .collect::>(); + let counts = [0, 1, 2].map(|code| codes.iter().filter(|value| **value == code).count()); + (counts, format!("{:x}", Sha256::digest(&codes))) +} + +const RECORDED: [([usize; 3], &str); 2] = [ + ( + [417, 2863, 1720], + "22153794c07f0549c60da52fa4442fef30cb55dfcee3bd6a3168f36f62ed78de", + ), + ( + [138, 1298, 1064], + "1b5f23003c8b925162d8f2aed4ad0206f30c7f4956a727dfc9e779bb71c98d2d", + ), +]; + +#[test] +fn one_face_trim_reused_for_every_probe_gives_the_recorded_answers_on_a_holed_d_and_a_tube() { + let bodies = probed_bodies(); + for (body, (counts, digest)) in bodies.iter().zip(RECORDED) { + let mut answers = Vec::new(); + for face in &body.topology.faces { + let trim = FaceTrim::new(body, face).unwrap(); + for uv in crossing_grid(face.trim.uv_bounds) { + let reused = trim.contains(body, face, uv).unwrap(); + let fresh = face_contains_uv(body, face, uv).unwrap(); + assert_eq!(reused, fresh, "{} face {} at {uv:?}", body.id, face.id); + answers.push(reused); + } + } + let (measured, sequence) = answer_record(&answers); + if stored_compared() { + assert_eq!(measured, counts, "{}", body.id); + assert_eq!(sequence, digest, "{}", body.id); + } + } + let faces = || { + bodies + .iter() + .flat_map(|body| body.topology.faces.iter().map(move |face| (body, face))) + }; + assert!(faces().any(|(_, face)| !face.trim.holes.is_empty())); + assert!(faces().any(|(body, face)| { + matches!( + body.geometry.surfaces[face.surface as usize], + SurfaceGeometry::Cylinder { .. } + ) && face.trim.holes.len() == 1 + })); +} + +fn edge_and_corner_probes(bounds: UVBox, depth: f64) -> [UV; 5] { + let [u, v] = bounds; + let (mid_u, mid_v) = (u.midpoint(), v.midpoint()); + [ + [mid_u, v.lo + depth], + [mid_u, v.hi - depth], + [u.lo + depth, mid_v], + [u.hi - depth, mid_v], + [u.lo + 0.7 * depth, v.lo + 0.7 * depth], + ] +} + +#[test] +fn a_probe_half_a_tolerance_inside_a_trim_edge_or_corner_is_boundary_and_three_tolerances_inside_is_inside( +) { + let body = primitives::cuboid( + "c".into(), + Frame3::IDENTITY, + [1.0, 1.0, 1.0], + Accuracy::STANDARD, + ) + .unwrap(); + let face = body + .topology + .faces + .iter() + .find(|face| { + matches!( + body.geometry.surfaces[face.surface as usize], + SurfaceGeometry::Plane { .. } + ) + }) + .unwrap(); + let bounds = face.trim.uv_bounds; + let surface = body.geometry.surface(face.surface).unwrap(); + let jet = surface + .derivatives([bounds[0].midpoint(), bounds[1].midpoint()]) + .unwrap(); + let tolerance = body.accuracy.geometric / norm(jet.du).min(norm(jet.dv)); + for (depth, expected) in [(0.5, None), (3.0, Some(true))] { + for uv in edge_and_corner_probes(bounds, depth * tolerance) { + let answer = face_contains_uv(&body, face, uv).unwrap(); + assert_eq!(answer, expected, "{depth} tolerances at {uv:?}"); + } + } +} diff --git a/main/opengeometry/src/query/tests.rs b/main/opengeometry/src/query/tests/mod.rs similarity index 75% rename from main/opengeometry/src/query/tests.rs rename to main/opengeometry/src/query/tests/mod.rs index 33a0cabc..9465023a 100644 --- a/main/opengeometry/src/query/tests.rs +++ b/main/opengeometry/src/query/tests/mod.rs @@ -1,14 +1,10 @@ use super::classification::PointClassification; use super::classify::{classify_point, classify_point_validated}; -use super::face_trim::{face_contains_uv, FaceTrim}; -use crate::brep::{ - coarse_accuracy, unit, Accuracy, BrepEnvelope, Frame3, SurfaceGeometry, UVBox, UV, -}; +use crate::brep::{coarse_accuracy, unit, Accuracy, BrepEnvelope, Frame3}; use crate::math::{add, cross, dot, scale, Point3}; -use crate::operations::modifying::boolean::{boolean_brep, BooleanOp}; -use crate::primitives::{self, ProfileEdge}; -use sha2::{Digest, Sha256}; -use std::f64::consts::PI; +use crate::primitives; + +mod face_trim; const STEP: f64 = 10.0 * Accuracy::STANDARD.geometric; const SURFACE: [PointClassification; 3] = [ @@ -437,138 +433,3 @@ fn cuboid_corners_edges_and_face_points_are_boundary() { .collect(), ); } - -fn crossing_grid(bounds: UVBox) -> Vec { - let steps = 24; - let at = move |axis: usize, index: usize| { - let interval = bounds[axis]; - interval.lo + interval.width() * (1.5 * index as f64 / steps as f64 - 0.25) - }; - (0..=steps) - .flat_map(|u| (0..=steps).map(move |v| [at(0, u), at(1, v)])) - .collect() -} - -fn tube() -> BrepEnvelope { - let rod = Frame3 { - origin: [0.0, 0.0, -0.5], - ..Frame3::IDENTITY - }; - let body = primitives::cylinder( - "body".into(), - Frame3::IDENTITY, - 0.3, - 1.0, - Accuracy::STANDARD, - ); - let bore = primitives::cylinder("bore".into(), rod, 0.1, 2.0, Accuracy::STANDARD); - boolean_brep( - &body.unwrap(), - &bore.unwrap(), - BooleanOp::Subtraction, - "tube".into(), - ) - .unwrap() - .brep -} - -fn holed_d() -> BrepEnvelope { - let half_turn = |start_angle| ProfileEdge::Arc { - center: [0.2, 0.2], - radius: 0.1, - start_angle, - sweep_angle: PI, - }; - let outer = vec![ - ProfileEdge::Line { - from: [0.0, 0.0], - to: [0.4, 0.0], - }, - ProfileEdge::Arc { - center: [0.4, 0.2], - radius: 0.2, - start_angle: -PI / 2.0, - sweep_angle: PI, - }, - ProfileEdge::Line { - from: [0.4, 0.4], - to: [0.0, 0.4], - }, - ProfileEdge::Line { - from: [0.0, 0.4], - to: [0.0, 0.0], - }, - ]; - let hole = vec![half_turn(0.0), half_turn(PI)]; - primitives::arc_edged_extrusion_with_holes( - "holed".into(), - Frame3::IDENTITY, - outer, - vec![hole], - 0.3, - Accuracy::STANDARD, - ) - .unwrap() -} - -fn probed_bodies() -> [BrepEnvelope; 2] { - [holed_d(), tube()] -} - -fn stored_compared() -> bool { - std::env::var("CI").as_deref() != Ok("true") -} - -fn answer_record(answers: &[Option]) -> ([usize; 3], String) { - let codes = answers - .iter() - .map(|answer| answer.map_or(0, |inside| 1 + u8::from(inside))) - .collect::>(); - let counts = [0, 1, 2].map(|code| codes.iter().filter(|value| **value == code).count()); - (counts, format!("{:x}", Sha256::digest(&codes))) -} - -const RECORDED: [([usize; 3], &str); 2] = [ - ( - [417, 2863, 1720], - "22153794c07f0549c60da52fa4442fef30cb55dfcee3bd6a3168f36f62ed78de", - ), - ( - [138, 1298, 1064], - "1b5f23003c8b925162d8f2aed4ad0206f30c7f4956a727dfc9e779bb71c98d2d", - ), -]; - -#[test] -fn one_face_trim_reused_for_every_probe_gives_the_recorded_answers_on_a_holed_d_and_a_tube() { - let bodies = probed_bodies(); - for (body, (counts, digest)) in bodies.iter().zip(RECORDED) { - let mut answers = Vec::new(); - for face in &body.topology.faces { - let trim = FaceTrim::new(body, face).unwrap(); - for uv in crossing_grid(face.trim.uv_bounds) { - let reused = trim.contains(body, face, uv).unwrap(); - let fresh = face_contains_uv(body, face, uv).unwrap(); - assert_eq!(reused, fresh, "{} face {} at {uv:?}", body.id, face.id); - answers.push(reused); - } - } - let (measured, sequence) = answer_record(&answers); - if stored_compared() { - assert_eq!(measured, counts, "{}", body.id); - assert_eq!(sequence, digest, "{}", body.id); - } - } - let faces = || { - bodies - .iter() - .flat_map(|body| body.topology.faces.iter().map(move |face| (body, face))) - }; - assert!(faces().any(|(_, face)| !face.trim.holes.is_empty())); - assert!(faces().any(|(body, face)| { - matches!( - body.geometry.surfaces[face.surface as usize], - SurfaceGeometry::Cylinder { .. } - ) && face.trim.holes.len() == 1 - })); -}