From fe9bdcdc027732f3b559afd317f77909821f549e Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Mon, 2 Nov 2026 14:49:28 +0100 Subject: [PATCH 1/4] Expose the exact curved inset as OpenGeometry.insetCurvedRegion2D A public static for a closed line-and-arc region, insetting through the kernel's exact curved inset. Declared API diff: + static insetCurvedRegion2D(region: CurvedRegion2D, distance: number): CurvedRegion2D[]; The open line-and-arc path offset is left for its own issue. --- README.md | 2 +- main/opengeometry-three/src/opengeometry.ts | 6 ++- .../src/operations/regions.ts | 14 ++++++- .../tests/node/offsets.test.mjs | 37 +++++++++++++++++++ main/opengeometry/src/bindings/geom2d.rs | 32 ++++++++++++++-- .../src/geom2d/offset/tests/curved_ring.rs | 30 ++++++++++++++- .../fixtures/regions/s-balcony-slab.json | 12 ++++++ 7 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 main/opengeometry/tests/fixtures/regions/s-balcony-slab.json diff --git a/README.md b/README.md index 6ea43d52..eda963d1 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o | STEP | `await OpenGeometry.exportStep({ nodes, unit?, upAxis?, name?, timestamp? })` returns `{ text, report }`. `nodes`: bodies, assemblies or `ogId` strings. `unit`: `'millimetre'` (default) or `'metre'`; `upAxis`: `'Z'` (default) or `'Y'`. | | IFC body | `OpenGeometry.exportIfcBody(solid)` returns the IFC exchange body (type `AnalyticExchangeBodyV2`): `schema_version` 2, world metres, Z up, `representation_type` `'AdvancedBrep'` or `'CSG'` for a solid with a cavity, `entities`, `solids` and `faces`. It refuses a Sheet, a Wire or an assembly. | | 2D regions | `OpenGeometry.booleanRegions2D(a, b, operation)` and `OpenGeometry.booleanCurvedRegions2D(a, b, operation)` with `'union'`, `'intersection'` or `'subtraction'`. A region is `{ outer, holes }`: an outer ring and a list of hole rings, each ring of `{ x, y, z }` points at one height (type `OffsetRegion`) or of line and arc edges (type `CurvedRegion2D`). Outer rings run clockwise in XZ and holes counter-clockwise. A plain ring is not a region and throws `InvalidParameter`; the tolerance is `OG_REGION_TOLERANCE`. | -| 2D offsets | `OpenGeometry.offsetRingVariable(ring, distances, holes?)` insets a closed ring by one distance per edge, each hole `{ ring, distances }` grown by its own; `OpenGeometry.offsetPolylineRegions(path, width, closed, miterLimit?)` strokes a path. Both return `CurvedRegion2D[]` with exact arcs. `OpenGeometry.offsetPolyline(path, distance, { acuteThresholdDegrees?, bevel?, closed? })` offsets a path to one side and returns `{ edges, bevelled, closed }` (type `OffsetPath2D`). Rings and paths are flat `[x, y, z, ...]` lists; `y` is ignored. | +| 2D offsets | `OpenGeometry.offsetRingVariable(ring, distances, holes?)` insets a closed ring by one distance per edge, each hole `{ ring, distances }` grown by its own; `OpenGeometry.offsetPolylineRegions(path, width, closed, miterLimit?)` strokes a path. Both return `CurvedRegion2D[]` with exact arcs. `OpenGeometry.insetCurvedRegion2D(region, distance)` insets one `CurvedRegion2D` by a distance above zero, holes grown by the same, and returns `CurvedRegion2D[]` of exact lines and arcs: each arc keeps its centre and a reflex corner rounds to an arc of the distance; an inset past the half width is an empty list. `OpenGeometry.offsetPolyline(path, distance, { acuteThresholdDegrees?, bevel?, closed? })` offsets a path to one side and returns `{ edges, bevelled, closed }` (type `OffsetPath2D`). Rings and paths are flat `[x, y, z, ...]` lists; `y` is ignored. | | `OGError` | Failures throw an `OGError` with `code` (for example `InvalidParameter`, `SharedShape`, `Disposed`), `call`, `message` and `details`. | | Reset | `OpenGeometry.reset()` frees every body and leaves an empty document. | | Typed parameters | `OG_PRIMITIVE_PARAMS_(params)`, `OG_OPERATION_PARAMS_EXTRUDE(params)`, `OG_OPERATION_PARAMS_SWEEP(params)` and `OG_OPERATION_PARAMS_REVOLVE(params)` return their argument unchanged; they only give the object a type. | diff --git a/main/opengeometry-three/src/opengeometry.ts b/main/opengeometry-three/src/opengeometry.ts index 4a24ec85..b3829e78 100644 --- a/main/opengeometry-three/src/opengeometry.ts +++ b/main/opengeometry-three/src/opengeometry.ts @@ -14,7 +14,7 @@ import { exportIfcBody } from './export/ifc-body'; import { createMark, markStats, type OGMark } from './marks/og-mark'; import { transaction } from './marks/transaction'; import { offsetPolyline, offsetPolylineRegions, offsetRingVariable } from './operations/offsets'; -import { booleanCurvedRegions2D, booleanRegions2D } from './operations/regions'; +import { booleanCurvedRegions2D, booleanRegions2D, insetCurvedRegion2D } from './operations/regions'; import { resolveHit } from './picking/resolve-hit'; import { flush } from './rendering/geometry/geometry-scheduler'; import { settled } from './rendering/geometry/settle'; @@ -81,6 +81,10 @@ export class OpenGeometry { operation: RegionOperation, ): CurvedRegion2D[] { return booleanCurvedRegions2D(a, b, operation); } + static insetCurvedRegion2D(region: CurvedRegion2D, distance: number): CurvedRegion2D[] { + return insetCurvedRegion2D(region, distance); + } + static offsetRingVariable( ring: readonly number[] | Float64Array, distances: readonly number[] | Float64Array, diff --git a/main/opengeometry-three/src/operations/regions.ts b/main/opengeometry-three/src/operations/regions.ts index 7e3471a9..72b4f6b3 100644 --- a/main/opengeometry-three/src/operations/regions.ts +++ b/main/opengeometry-three/src/operations/regions.ts @@ -1,12 +1,14 @@ import type { CurvedRegion2D, OffsetRegion, RegionOperation } from '../dto/regions'; +import { OGError } from '../errors'; import { call } from '../kernel/kernel-session'; import { - curvedRegionsJson, decodeCurvedRegions, decodeRegions, regionOperation, regionsJson, + curvedRegionsJson, decodeCurvedRegions, decodeRegions, encode, regionOperation, regionsJson, } from '../world-graph/codec'; import { worldGraph } from '../world-graph/world-graph-client'; const PLANAR = 'OpenGeometry.booleanRegions2D'; const CURVED = 'OpenGeometry.booleanCurvedRegions2D'; +const INSET = 'OpenGeometry.insetCurvedRegion2D'; export function booleanRegions2D( a: readonly OffsetRegion[], @@ -30,3 +32,13 @@ export function booleanCurvedRegions2D( const text = call(CURVED, () => worldGraph().booleanCurvedRegions2D(aJson, bJson, kind)); return decodeCurvedRegions(text, CURVED); } + +export function insetCurvedRegion2D(region: CurvedRegion2D, distance: number): CurvedRegion2D[] { + const given: unknown = region; + if (typeof given !== 'object' || given === null || Array.isArray(given)) { + throw new OGError('InvalidParameter', INSET, 'region must be one { outer, holes } of line and arc edges'); + } + if (typeof distance !== 'number') throw new OGError('InvalidParameter', INSET, 'distance must be a number'); + const regionJson = encode(region); + return decodeCurvedRegions(call(INSET, () => worldGraph().insetCurvedRegion2D(regionJson, distance)), INSET); +} diff --git a/main/opengeometry-three/tests/node/offsets.test.mjs b/main/opengeometry-three/tests/node/offsets.test.mjs index b9c323cc..be8f146d 100644 --- a/main/opengeometry-three/tests/node/offsets.test.mjs +++ b/main/opengeometry-three/tests/node/offsets.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { OpenGeometry } from '../../../dist/index.js'; import { boot, failsWith, listen } from './support.mjs'; @@ -35,6 +36,11 @@ function area(regions) { - region.holes.reduce((holes, ring) => holes + Math.abs(ringArea(ring)), 0), 0); } +function length(edge) { + if (edge.kind === 'arc') return edge.radius * Math.abs(edge.sweep_angle); + return Math.hypot(edge.to[0] - edge.from[0], edge.to[1] - edge.from[1]); +} + function arcs(regions) { return regions.flatMap((region) => [region.outer, ...region.holes].flat()).filter((edge) => edge.kind === 'arc'); } @@ -46,6 +52,9 @@ function near(a, b, tolerance = 1e-9) { const SQUARE_LOT = [[0, 0], [20, 0], [20, 20], [0, 20]]; const HOLE = [[9, 9], [11, 9], [11, 11], [9, 11]]; const L_LOT = [[0, 0], [10, 0], [10, 5], [5, 5], [5, 10], [0, 10]]; +const S_BALCONY = JSON.parse(readFileSync( + new URL('../../../opengeometry/tests/fixtures/regions/s-balcony-slab.json', import.meta.url), 'utf8', +)); test('an envelope inset is exact, with each hole corner rounded by its own distance', () => { const y = 2; @@ -83,6 +92,34 @@ test('the ring height is ignored, and a typed array gives the same result as a l assert.deepEqual(high, low); }); +test('the S balcony slab insets by 1.2 m to the same seven edges, its two arcs on their own centres', () => { + const regions = OpenGeometry.insetCurvedRegion2D(S_BALCONY, 1.2); + const [inset] = regions; + assert(inset && regions.length === 1); + assert.equal(inset.holes.length, 0); + assert.equal(inset.outer.length, S_BALCONY.outer.length); + const rounded = arcs(regions); + assert.equal(rounded.length, 2); + for (const [source, radius] of arcs([S_BALCONY]).map((arc, index) => [arc, [4.8, 7.2][index]])) { + assert(rounded.some((arc) => near(arc.radius, radius) + && near(arc.center[0], source.center[0]) && near(arc.center[1], source.center[1]))); + } + const perimeter = S_BALCONY.outer.reduce((sum, edge) => sum + length(edge), 0); + assert(near(area(regions), area([S_BALCONY]) - 1.2 * perimeter + 4 * 1.2 * 1.2)); +}); + +test('a curved inset past the half width is empty, and a bad region or distance is refused', () => { + assert.deepEqual(OpenGeometry.insetCurvedRegion2D(S_BALCONY, 8), []); + for (const distance of [0, -1.2, Number.NaN, Number.POSITIVE_INFINITY, wire('1.2')]) { + assert.throws(() => OpenGeometry.insetCurvedRegion2D(S_BALCONY, distance), failsWith('InvalidParameter')); + } + for (const region of [wire([S_BALCONY]), wire(null), wire({ ...S_BALCONY, colour: 'red' })]) { + assert.throws(() => OpenGeometry.insetCurvedRegion2D(region, 1.2), failsWith('InvalidParameter')); + } + const open = { outer: S_BALCONY.outer.slice(0, 4), holes: [] }; + assert.throws(() => OpenGeometry.insetCurvedRegion2D(open, 1.2), failsWith('UnresolvedIntersection')); +}); + test('a routed duct run strokes to width times length, mitred at the default limit of 4 and bevelled at 1', () => { const run = flat([[0, 0], [4, 0], [4, 3], [8, 3]]); const regions = OpenGeometry.offsetPolylineRegions(run, 0.3, false, 4); diff --git a/main/opengeometry/src/bindings/geom2d.rs b/main/opengeometry/src/bindings/geom2d.rs index 8b50af4e..1379c8ac 100644 --- a/main/opengeometry/src/bindings/geom2d.rs +++ b/main/opengeometry/src/bindings/geom2d.rs @@ -1,8 +1,8 @@ use crate::bindings::{errors, OGWorldGraph}; use crate::geom2d::{ - boolean_curved_regions, boolean_oriented_regions, offset_path, offset_polyline_regions, - offset_ring_variable, signed_area2, CurveEdge2, CurveRegion2, OffsetError, OffsetHole, - OffsetOptions, PlanarBooleanOp, Pt2, RingRegion, + boolean_curved_regions, boolean_oriented_regions, inset_curved_region, inset_tolerance, + offset_path, offset_polyline_regions, offset_ring_variable, signed_area2, CurveEdge2, + CurveRegion2, OffsetError, OffsetHole, OffsetOptions, PlanarBooleanOp, Pt2, RingRegion, }; use crate::world_graph::{ErrorCode, GraphError}; use serde::de::DeserializeOwned; @@ -73,6 +73,16 @@ impl OGWorldGraph { serde_json::to_string(®ions).map_err(errors::serialization) } + #[wasm_bindgen(js_name = insetCurvedRegion2D)] + pub fn inset_curved_region_2d( + &self, + region_json: &str, + distance: f64, + ) -> Result { + let regions = curved_inset(region_json, distance).map_err(errors::json)?; + serde_json::to_string(®ions).map_err(errors::serialization) + } + #[wasm_bindgen(js_name = offsetRingVariable)] pub fn offset_ring_variable( &self, @@ -266,6 +276,22 @@ fn curved_regions( boolean_curved_regions(&a, &b, operation, REGION_TOLERANCE).map_err(GraphError::from) } +fn curved_inset(region_json: &str, distance: f64) -> Result, GraphError> { + if !distance.is_finite() || distance <= 0.0 { + return Err(GraphError::code( + ErrorCode::InvalidParameter, + "inset distance must be a finite number above zero", + )); + } + let region: CurveRegion2 = parsed(region_json)?; + check_size( + region.outer.len() + region.holes.iter().map(Vec::len).sum::(), + "inset region edges", + )?; + let tolerance = inset_tolerance(®ion, REGION_TOLERANCE); + inset_curved_region(®ion, distance, tolerance).map_err(GraphError::from) +} + fn operation_of(text: &str) -> Result { match text { "union" => Ok(PlanarBooleanOp::Union), diff --git a/main/opengeometry/src/geom2d/offset/tests/curved_ring.rs b/main/opengeometry/src/geom2d/offset/tests/curved_ring.rs index 89e24ca5..21b57b4d 100644 --- a/main/opengeometry/src/geom2d/offset/tests/curved_ring.rs +++ b/main/opengeometry/src/geom2d/offset/tests/curved_ring.rs @@ -1,7 +1,7 @@ use super::polyline::filled; use crate::geom2d::boolean2d::distance_to_segment; use crate::geom2d::curved_boolean2d::{CurveEdge2, CurveRegion2}; -use crate::geom2d::offset::inset_curved_region; +use crate::geom2d::offset::{inset_curved_region, inset_tolerance}; use crate::geom2d::poly2d::Pt2; use crate::geom2d::tests::{curve_ring_area, p, DEFAULT_EPS}; use std::f64::consts::{FRAC_PI_2, PI, TAU}; @@ -90,6 +90,13 @@ fn square_with_round_hole() -> CurveRegion2 { ) } +fn s_balcony_slab() -> CurveRegion2 { + serde_json::from_str(include_str!( + "../../../../tests/fixtures/regions/s-balcony-slab.json" + )) + .unwrap() +} + fn rings(region: &CurveRegion2) -> impl Iterator> { std::iter::once(®ion.outer).chain(®ion.holes) } @@ -281,3 +288,24 @@ fn a_thickness_past_the_half_width_collapses_to_nothing() { .unwrap() .is_empty()); } + +#[test] +fn the_s_balcony_slab_insets_by_1_2_m_to_the_same_seven_edges_with_exact_arcs() { + let source = s_balcony_slab(); + let result = inset_curved_region(&source, 1.2, inset_tolerance(&source, DEFAULT_EPS)).unwrap(); + assert_at_distance(&result, &source, 1.2); + assert_eq!(result.len(), 1); + assert!(result[0].holes.is_empty()); + assert_eq!(result[0].outer.len(), source.outer.len()); + assert_eq!(arcs(&result).len(), 2); + let crest = 4.0 + 2.0 * 11.0_f64.sqrt(); + assert!(has_arc(&result, [4.0, -6.0], 4.8)); + assert!(has_arc(&result, [crest, 4.0], 7.2)); + let perimeter: f64 = source.outer.iter().map(CurveEdge2::length).sum(); + let expected = area(std::slice::from_ref(&source)) - 1.2 * perimeter + 4.0 * 1.2 * 1.2; + assert!( + (area(&result) - expected).abs() <= 1e-9, + "{}", + area(&result) + ); +} diff --git a/main/opengeometry/tests/fixtures/regions/s-balcony-slab.json b/main/opengeometry/tests/fixtures/regions/s-balcony-slab.json new file mode 100644 index 00000000..4b055383 --- /dev/null +++ b/main/opengeometry/tests/fixtures/regions/s-balcony-slab.json @@ -0,0 +1,12 @@ +{ + "outer": [ + {"kind":"line","from":[0,0],"to":[4,0]}, + {"kind":"arc","center":[4,-6],"radius":6,"start_angle":1.5707963267948966,"sweep_angle":-0.585685543457151}, + {"kind":"arc","center":[10.6332495807108,4],"radius":6,"start_angle":-2.1564818702520476,"sweep_angle":0.5856855434571511}, + {"kind":"line","from":[10.6332495807108,-2],"to":[14.6332495807108,-2]}, + {"kind":"line","from":[14.6332495807108,-2],"to":[14.6332495807108,-15]}, + {"kind":"line","from":[14.6332495807108,-15],"to":[0,-15]}, + {"kind":"line","from":[0,-15],"to":[0,0]} + ], + "holes": [] +} From a86a9254081139a40d3054443e296f45ddf5cde7 Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Mon, 2 Nov 2026 19:47:29 +0100 Subject: [PATCH 2/4] Give each repeated tool instance its own face keys in one operate call; refuse a tool listed twice --- .../opengeometry/src/world_graph/modifying.rs | 19 ++- main/opengeometry/tests/booleans/atomicity.rs | 110 +++++++++++++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/main/opengeometry/src/world_graph/modifying.rs b/main/opengeometry/src/world_graph/modifying.rs index 3392809c..8a2a6364 100644 --- a/main/opengeometry/src/world_graph/modifying.rs +++ b/main/opengeometry/src/world_graph/modifying.rs @@ -9,6 +9,7 @@ use crate::operations::modifying::boolean::{ multi_tool_boolean, BooleanOp, BooleanResult, MultiToolOutcome, }; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use std::sync::Arc; #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -123,7 +124,15 @@ impl WorldGraph { tools: &[String], shape_id: &ShapeId, ) -> Result, GraphError> { + let mut listed = BTreeSet::new(); + if !tools.iter().all(|tool| listed.insert(tool)) { + return Err(GraphError::code( + ErrorCode::DuplicateNode, + "tool is listed twice", + )); + } let mut mapped = Vec::with_capacity(tools.len()); + let mut mapped_shapes = BTreeSet::new(); for tool in tools { let tool_node = self.node(tool)?; if tool == target || tool_node.shape.as_ref() == Some(shape_id) { @@ -141,11 +150,17 @@ impl WorldGraph { "tool has a relative scale", )); } - mapped.push(if is_identity(transform) { + let mut brep = if is_identity(transform) { (*shape.brep).clone() } else { placed(&shape.brep, transform.frame, 1.0)? - }); + }; + if !mapped_shapes.insert(tool_node.shape.clone()) { + for face in &mut brep.topology.faces { + face.key = format!("{tool}:{}", face.key); + } + } + mapped.push(brep); } Ok(mapped) } diff --git a/main/opengeometry/tests/booleans/atomicity.rs b/main/opengeometry/tests/booleans/atomicity.rs index 70aa1043..86b49dc3 100644 --- a/main/opengeometry/tests/booleans/atomicity.rs +++ b/main/opengeometry/tests/booleans/atomicity.rs @@ -1,4 +1,4 @@ -use super::support::cuboid; +use super::support::{assert_valid_closed, assert_volume, cuboid}; use opengeometry::brep::FaceRole; use opengeometry::world_graph::{ CopyOptions, EditScope, ErrorCode, ErrorDetails, GraphError, ModifyingOperation, Primitive, @@ -124,6 +124,114 @@ fn shared_target_requires_explicit_all_instances_and_tool_instance_is_allowed() ); } +fn plate_cut_by_instances_of_one_cylinder(count: usize) { + let mut world = graph(); + world + .create_primitive( + Primitive::Cuboid { + width: 12.0, + height: 0.5, + depth: 4.0, + }, + named("plate"), + ) + .unwrap(); + world + .create_primitive( + Primitive::Cylinder { + radius: 0.2, + height: 2.0, + }, + named("hole-0"), + ) + .unwrap(); + let mut tools = vec!["hole-0".to_string()]; + for index in 1..count { + let (og_id, _) = world + .instance( + "hole-0", + CopyOptions { + og_id: Some(format!("hole-{index}")), + ..CopyOptions::default() + }, + ) + .unwrap(); + tools.push(og_id); + } + for (index, tool) in tools.iter().enumerate() { + world + .transform( + tool, + Transform::Translate { + offset: [-5.4 + 1.2 * index as f64, -0.5, 0.0], + }, + ) + .unwrap(); + } + world + .operate( + "plate", + ModifyingOperation::Subtract, + &tools, + EditScope::Node, + ) + .unwrap(); + let plate = world.brep("plate").unwrap(); + assert_valid_closed(&plate, 1, "plate"); + assert_eq!(plate.revision, 1); + let mut handlers = vec!["cylinder_box_boolean"]; + handlers.resize(count, "generic_two_sided_cutter_band_subtraction"); + assert_eq!( + world.report("plate").unwrap().unwrap()["handlers"], + serde_json::json!(handlers) + ); + let removed = count as f64 * std::f64::consts::PI * 0.2 * 0.2 * 0.5; + assert_volume(volume::estimate(&plate, 0.01), 24.0 - removed, "plate"); +} + +#[test] +fn two_instances_of_one_tool_cut_a_plate_in_one_call() { + plate_cut_by_instances_of_one_cylinder(2); +} + +#[test] +fn ten_instances_of_one_tool_cut_a_plate_in_one_call() { + plate_cut_by_instances_of_one_cylinder(10); +} + +#[test] +fn node_listed_twice_as_a_tool_is_refused_without_a_commit() { + let mut world = graph(); + world.create_primitive(cuboid(2.0), named("host")).unwrap(); + world.create_primitive(cuboid(2.0), named("tool")).unwrap(); + world + .transform( + "tool", + Transform::Translate { + offset: [1.0, 0.0, 0.0], + }, + ) + .unwrap(); + let revision = world.revision(); + let before = world.brep("host").unwrap().to_json().unwrap(); + let report = world.report("host").unwrap().cloned(); + assert_eq!( + world + .operate( + "host", + ModifyingOperation::Subtract, + &["tool".into(), "tool".into()], + EditScope::Node + ) + .unwrap_err() + .error_code(), + ErrorCode::DuplicateNode + ); + assert_eq!(world.revision(), revision); + assert_eq!(world.brep("host").unwrap().to_json().unwrap(), before); + assert_eq!(world.report("host").unwrap().cloned(), report); +} + #[test] fn empty_intersection_and_tool_limit_leave_graph_unchanged() { let mut world = graph(); From 0503e4d4b9c0d802f72a71db346146d2b7ca805a Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Mon, 2 Nov 2026 20:25:56 +0100 Subject: [PATCH 3/4] Flip the collinear ear out of a planar cap at an arc-arc tangent --- .../src/tessellation/planar_face.rs | 38 +++++- .../opengeometry/tests/creating/mesh_walls.rs | 127 +++++++++++++++++- .../fixtures/golden/aarch64-apple-darwin.json | 16 +-- 3 files changed, 170 insertions(+), 11 deletions(-) diff --git a/main/opengeometry/src/tessellation/planar_face.rs b/main/opengeometry/src/tessellation/planar_face.rs index 15a56d7f..1d4bdf7b 100644 --- a/main/opengeometry/src/tessellation/planar_face.rs +++ b/main/opengeometry/src/tessellation/planar_face.rs @@ -1,6 +1,6 @@ use super::mesh::Tessellation; use crate::brep::{BrepEnvelope, Face, GeometryError, Orientation, Surface}; -use crate::math::{cross, dot, scale, sub, Point3}; +use crate::math::{cross, dot, norm, scale, sub, Point3}; pub(super) fn planar_face( brep: &BrepEnvelope, @@ -53,12 +53,13 @@ pub(super) fn planar_face( )); } } - let triangles = earcutr::earcut(&uv_flat, &holes, 2); + let mut triangles = earcutr::earcut(&uv_flat, &holes, 2); if triangles.is_empty() && points.len() >= 3 { return Err(GeometryError::InvalidTopology( "planar triangulation failed".into(), )); } + flip_collinear_ears(&mut triangles, &points, normal, brep.accuracy.geometric); let mut ids = Vec::with_capacity(points.len()); for p in points { ids.push(mesh.vertex(p, normal)?); @@ -81,3 +82,36 @@ pub(super) fn planar_face( } Ok(()) } + +fn flip_collinear_ears(triangles: &mut [usize], points: &[Point3], normal: Point3, tolerance: f64) { + let twice = |[a, b, c]: [usize; 3]| { + dot( + cross(sub(points[b], points[a]), sub(points[c], points[a])), + normal, + ) + }; + for t in 0..triangles.len() / 3 { + let tri = [triangles[3 * t], triangles[3 * t + 1], triangles[3 * t + 2]]; + let lengths = + [0, 1, 2].map(|k| norm(sub(points[tri[(k + 2) % 3]], points[tri[(k + 1) % 3]]))); + let k = (0..3) + .max_by(|&i, &j| lengths[i].total_cmp(&lengths[j])) + .unwrap_or(0); + if twice(tri).abs() > tolerance * lengths[k] { + continue; + } + let [b, c, a] = [tri[k], tri[(k + 1) % 3], tri[(k + 2) % 3]]; + let Some((u, d)) = (0..triangles.len() / 3).filter(|&u| u != t).find_map(|u| { + (0..3) + .find(|&j| triangles[3 * u + j] == a && triangles[3 * u + (j + 1) % 3] == c) + .map(|j| (u, triangles[3 * u + (j + 2) % 3])) + }) else { + continue; + }; + let side = twice([a, c, d]); + if twice([a, b, d]) * side > 0.0 && twice([b, c, d]) * side > 0.0 { + triangles[3 * t..3 * t + 3].copy_from_slice(&[a, b, d]); + triangles[3 * u..3 * u + 3].copy_from_slice(&[b, c, d]); + } + } +} diff --git a/main/opengeometry/tests/creating/mesh_walls.rs b/main/opengeometry/tests/creating/mesh_walls.rs index bb456858..47efea97 100644 --- a/main/opengeometry/tests/creating/mesh_walls.rs +++ b/main/opengeometry/tests/creating/mesh_walls.rs @@ -1,7 +1,9 @@ use opengeometry::brep::BrepEnvelope; use opengeometry::primitives::{ProfileSegment, SegmentArc}; use opengeometry::tessellation::display::{deflection_bucket, display_buffers, static_bucket}; -use opengeometry::world_graph::{CreatingOperation, EditScope, Primitive, WorldGraph}; +use opengeometry::world_graph::{ + CreatingOperation, EditScope, ModifyingOperation, Primitive, Transform, WorldGraph, +}; use opengeometry_test_support::world_graph::{graph, named}; use std::collections::HashMap; @@ -170,3 +172,126 @@ fn shell_of_swept_arc_wall_has_no_needles_and_every_edge_shared_by_two_triangles world.shell("solid", 0.02, EditScope::Node).unwrap(); assert_clean_walls(&world.brep("solid").unwrap()); } + +fn s_slab() -> WorldGraph { + let theta = (5.0_f64 / 6.0).acos(); + let c1 = [4.0, 6.0]; + let angle = -std::f64::consts::FRAC_PI_2 + theta; + let p1 = [c1[0] + 6.0 * angle.cos(), c1[1] + 6.0 * angle.sin()]; + let c2 = [p1[0] + 6.0 * theta.sin(), p1[1] - 6.0 * theta.cos()]; + let p2 = [c2[0], c2[1] + 6.0]; + let end = [p2[0] + 4.0, p2[1]]; + let line = |to: [f64; 2]| ProfileSegment { to, arc: None }; + let arc = |to: [f64; 2], center: [f64; 2], counter_clockwise: bool| ProfileSegment { + to, + arc: Some(SegmentArc { + center, + counter_clockwise, + }), + }; + let mut world = graph(); + world + .create_primitive( + Primitive::Profile { + start: [0.0, 0.0], + segments: vec![ + line([4.0, 0.0]), + arc(p1, c1, true), + arc(p2, c2, false), + line(end), + line([end[0], 15.0]), + line([0.0, 15.0]), + line([0.0, 0.0]), + ], + closed: true, + }, + named("profile"), + ) + .unwrap(); + world + .create_operation( + CreatingOperation::Extrude { + profile: "profile".into(), + holes: Vec::new(), + distance: 0.25, + }, + named("slab"), + ) + .unwrap(); + world +} + +fn plate_with_thirty_holes() -> WorldGraph { + let mut world = graph(); + world + .create_primitive( + Primitive::Cuboid { + width: 6.0, + height: 0.05, + depth: 3.6, + }, + named("plate"), + ) + .unwrap(); + let mut tools = Vec::new(); + for i in 0..6 { + for j in 0..5 { + let og_id = format!("hole-{i}-{j}"); + world + .create_primitive( + Primitive::Cylinder { + radius: 0.05, + height: 0.25, + }, + named(&og_id), + ) + .unwrap(); + world + .transform( + &og_id, + Transform::Translate { + offset: [ + -0.75 + 0.3 * f64::from(i), + -0.1, + -0.48 + 0.24 * f64::from(j), + ], + }, + ) + .unwrap(); + tools.push(og_id); + } + } + world + .operate( + "plate", + ModifyingOperation::Subtract, + &tools, + EditScope::Node, + ) + .unwrap(); + world +} + +fn display_buckets(body: &BrepEnvelope) -> [f64; 3] { + [ + static_bucket(body).unwrap(), + deflection_bucket(0.002).unwrap(), + deflection_bucket(0.004).unwrap(), + ] +} + +#[test] +fn s_slab_cap_has_no_zero_area_triangle_at_the_arc_arc_tangent() { + let body = s_slab().brep("slab").unwrap(); + for bucket in display_buckets(&body) { + assert_eq!(faults(&body, bucket), Faults::default(), "bucket {bucket}"); + } +} + +#[test] +fn plate_cut_by_thirty_holes_has_no_zero_area_triangle() { + let body = plate_with_thirty_holes().brep("plate").unwrap(); + for bucket in display_buckets(&body) { + assert_eq!(faults(&body, bucket).zero_area, 0, "bucket {bucket}"); + } +} diff --git a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json index 1cb01308..3a95a309 100644 --- a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json +++ b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json @@ -26,8 +26,8 @@ "batch.staged.overlapping-planar-cutters": "a30c4a037bb67052c1c0aa9fd40f7f85375e67d686dbe39df0b24afbff44f769", "batch.staged.pitched-planar-stage": "d836bc45e2782208a14afd2a549930daf0f65859aadc2978454282f84741a442", "batch.staged.skew-planar-stage": "82d65e7cc11abb941de55d728c7d5823f0b6fc9e1e93be37254fde6a099e497b", - "batch.vertical-arc.arc-host": "bdf291b00dd71cebe80ef6e5c923f13e8330d6e1981ff41c3987a2f3644ac0a0", - "batch.vertical-arc.wide-opening": "19a0abff5cbc8533d1abf646d16b961659db6d4f1ed2d30fdfa469d25ea3a463", + "batch.vertical-arc.arc-host": "55cfee69c2841b3c3f6eb4cbd483b5bd1ef8e36963989468a1afb5c758fa5a00", + "batch.vertical-arc.wide-opening": "b3f2cadf32878cd4f6d0887e3ab2e5c07a46ea5041e9812d6532a318ea24999c", "booleans.boxes-direct.aligned.intersection": "d106f0de05faa7c01f7d28a4b165e0e1fede6c9ed88bb914a0f6df0d6b0184de", "booleans.boxes-direct.aligned.subtraction": "f9aa99ea12313b04fa049d62ff0ce960995b6afddab085d3a5e80156d031e1b4", "booleans.boxes-direct.aligned.union": "1a534db5a6c5877d3a30f15f82c032b3de46eeb8da43d5c1363907d5978d156c", @@ -65,9 +65,9 @@ "booleans.cuboid-containment.crossing-union": "9123c2dd5db1083c15a487e4783fbc06a2a4f254c2f46db6a6ffc52710337c80", "booleans.cuboid-containment.cylinder-in-box": "79f8db84f27d3960e759853980540b38dd670b88784676ee3ebad5f987ca8076", "booleans.cuboid-containment.sphere-in-box": "26e5adeebf7074940c50768d64868bd156c134cec39289ebc14e1370565e21c4", - "booleans.curved.arc-host-chained": "f891edcb50bf7707fc5c3ee038ab712cd4300a5f3344fc1dd423c2fcf6318cb4", + "booleans.curved.arc-host-chained": "539e90b8cb6de0c06bed87b3139be30c6cae53660619570d4e6b0ed1edf8db29", "booleans.curved.arc-host-opening": "9a4edac6c1d62b70b7849b0fd75388a651a07845f6433dbc96a18eec1e3ea677", - "booleans.curved.arc-host-third": "8fb39cbc9ebef681c7d07a6f0df1910d2bb2dc65b760cd636611382e4fc657a4", + "booleans.curved.arc-host-third": "796655af6d5b2123dcd5258fc50896dd652010fe16fe79ef7ac9c4f5a8a09eba", "booleans.curved.internal-cavity": "d80cbec2a5d6960ae84776869a8bc03d7b867dbc6cfbb84622d5f3a203ef1486", "booleans.curved.prismatic.block-pocket.intersection": "7ac6cd0fdc3f3bb2d5b5dda1436ea7925ed4b6afbb1ebde0df3f05b95b65f03b", "booleans.curved.prismatic.block-pocket.subtraction": "5fdd32a0818591eee67f3333993fffcf29917f41230f57bdfd3b1238d24e29ca", @@ -86,14 +86,14 @@ "booleans.curved.ring-arched-seam": "c958dbddb96340323e35cb48282012173521f2f45c431b10b4e46e260a91282c", "booleans.curved.ring-round-window": "c1169582ed69d7308783ec9321203ec69fc53e5652b86eb991ece24a0b986991", "booleans.curved.ring-sector": "f7a3c51f73e4f17b80f087f86e6b945c87617b2fbcca6bc64d746bb36e89380d", - "booleans.curved.ring-sector-chained": "0fea2aaef8c6b6df8a2bad4821c13d5a54ebf54681a46d7a5d02f35314722cb7", + "booleans.curved.ring-sector-chained": "1398db06e9c8fd19826edd51ce024fa6880af0a342a0184e34119b2fe687a631", "booleans.curved.same-component-cavities": "360774653d5cdadcef648d9dcec71b10943289cdf52f7d154723f98973878dbb", "booleans.curved.split-arc-provenance": "67c6d42dd213b8970aaf3d300b61bc36b86f6594deb6a4f0863094b10ef8b289", "booleans.curved.split-cap-first": "65a5c916a32a1e7ba2b4eb9976020c4a2391079ad426da1f9dddbd8d9a4dfc31", "booleans.curved.split-cap-second": "102caeec6687e04cc778b10b8d3930845e3bb7fd0fcc10d5622543aff52f5abf", "booleans.curved.split-sector": "1c0db629e55031610c41f96e592bbb198537377a8fd177c346b4e6931a73b19c", - "booleans.curved.split-with-cavity": "c266e7a04b7dd71ce39b179da70b810e6c5d75fbc30962305bcc05097f532d0d", - "booleans.curved.two-cavities": "f963833b49e6b37ce3980e68830df74dddfebf15a16ce57fd6553ec7eebdcb04", + "booleans.curved.split-with-cavity": "9b46456cf5866dafc2e9fcaaffb7e9f04bb7af6e10824bce4d8515453a9ee616", + "booleans.curved.two-cavities": "f84fdd3b7cb73aeab1133de9cc710140c4f8782bb8947bad98ba1ba64c3d5940", "booleans.curved.wall-arched-door": "145d4d088d824e81ffffb0db184418627b5c517f3f067386c901949f7afdb762", "booleans.curved.wall-arched-opening": "5a70df2ae80fb5b62a96cb4434a7948ff88530764b6347680a8072e1656c26df", "booleans.cylinder-box.oblique-20.intersection": "b3ca091c70b65ad996211459c1a7fbca685a470d29a92139ce18a037ac814a6d", @@ -251,7 +251,7 @@ "booleans.planar.angled-full-height": "9620d40ce706185225ec6b329c24e760fe8ab2eafc844418f3e510e4752436d7", "booleans.planar.angled-lower-cutout": "efcb3fe5b2fc1ec1ab38ad170aafc2e68d467614b964dec47570d2788ad01339", "booleans.planar.angled-overlapping-twice": "8bbd43311c9c82e05c0c48deba8403d74541823ee3d2953385103158749de057", - "booleans.planar.angled-rotated-cutter": "3315add1b93525fd17dee63846790b6cd0669bae6756dc2c78d835ed51c7bb1e", + "booleans.planar.angled-rotated-cutter": "9a1b4cbff08a7cf32739e966bdfc1f8cce76ed8e9e823b421e4b4ad5772fbe5d", "booleans.planar.arched-cap": "deb2334b1e820a94852ba5efc6f6aac282dd078e75ce9e097cb71378f9b9debe", "booleans.planar.arched-chained": "6ac5ae4f65ce7d7cdd315ba69cd87b0023a841613775d55d8d6feade4e35de3d", "booleans.planar.axial-disjoint.intersection": "97e3ea19be8f90011a13888d662584708e8554c68caa051375c0f7c27da5aa5f", From 6cc15fddc8820df3f559eb0f7b505aac820f6120 Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Tue, 3 Nov 2026 18:43:50 +0100 Subject: [PATCH 4/4] Make a halfspace subtract on a large planar body near-linear, and the by-handle check O(1) A planar subtract validated its host on every classify call and compared every edge, ring, vertex and face with every other. It now validates once, keeps one cached-bounds classifier per body, and visits only box-near pairs in the original order, falling back to every pair when the margin is unbounded or the pair count passes a budget. The binding caches body type per shape and a verified handle to ogId map. Results are unchanged; the 300-hole perforated plate's section draws in 4.4 s (was 13 s). --- .../src/bindings/world_graph/hierarchy.rs | 41 +++++++++- .../src/bindings/world_graph/mod.rs | 10 ++- main/opengeometry/src/geom2d/boolean2d.rs | 56 ++++++++----- main/opengeometry/src/geom2d/mod.rs | 3 +- main/opengeometry/src/geom2d/poly2d.rs | 82 ++++++++++++++++++- .../src/geom2d/tests/boolean2d.rs | 60 +++++++++++++- .../modifying/boolean/assembly/append.rs | 35 ++++---- .../boolean/handlers/planar/facets.rs | 31 ++++++- .../boolean/handlers/planar/polyhedra/mod.rs | 55 +++++++------ .../handlers/planar/polyhedra/sections.rs | 48 ++++++++++- .../primitives/internal/linear_extrusion.rs | 28 ++++++- main/opengeometry/src/query/classify.rs | 64 +++++++++++++-- main/opengeometry/src/query/mod.rs | 1 + main/opengeometry/src/query/ray_roots.rs | 6 +- main/scripts/test/bindings.test.mjs | 14 ++++ 15 files changed, 455 insertions(+), 79 deletions(-) diff --git a/main/opengeometry/src/bindings/world_graph/hierarchy.rs b/main/opengeometry/src/bindings/world_graph/hierarchy.rs index bc1ba5eb..25af3d49 100644 --- a/main/opengeometry/src/bindings/world_graph/hierarchy.rs +++ b/main/opengeometry/src/bindings/world_graph/hierarchy.rs @@ -1,6 +1,8 @@ use super::{changed, check_copy, created, serialise, OGWorldGraph}; use crate::bindings::{errors, params}; +use crate::brep::{BodyType, BrepEnvelope, GeometryError}; use crate::world_graph::CopyOptions; +use std::sync::Arc; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; @@ -8,13 +10,20 @@ use wasm_bindgen::JsValue; impl OGWorldGraph { pub fn node(&self, og_id: &str) -> Result { let node = self.inner.node(og_id).map_err(errors::json)?; + let mut handles = self.handles.borrow_mut(); + if handles.len() > 2 * self.inner.node_count() { + handles.clear(); + } + handles.insert((node.handle, node.generation), node.og_id.clone()); + drop(handles); let shape = if node.shape.is_some() { Some(self.inner.shape(og_id).map_err(errors::json)?) } else { None }; let body_type = shape - .map(|value| value.brep.body_type()) + .zip(node.shape.as_deref()) + .map(|(value, shape_id)| self.body_type(shape_id, &value.brep)) .transpose() .map_err(|error| errors::json(error.into()))? .map(|kind| format!("{kind:?}")); @@ -33,6 +42,14 @@ impl OGWorldGraph { #[wasm_bindgen(js_name = nodeByHandle)] pub fn node_by_handle(&self, handle: u32, generation: u32) -> Result { + let cached = self.handles.borrow().get(&(handle, generation)).cloned(); + if let Some(og_id) = cached { + if let Ok(node) = self.inner.node(&og_id) { + if node.handle == handle && node.generation == generation { + return self.node(&og_id); + } + } + } let node = self .inner .node_by_handle(handle, generation) @@ -108,3 +125,25 @@ impl OGWorldGraph { changed(self.inner.dispose(og_id).map_err(errors::json)?) } } + +impl OGWorldGraph { + fn body_type( + &self, + shape_id: &str, + brep: &Arc, + ) -> Result { + let mut cache = self.body_types.borrow_mut(); + if let Some((cached, kind)) = cache.get(shape_id) { + if cached.as_ptr() == Arc::as_ptr(brep) { + return Ok(*kind); + } + } + let kind = brep.body_type()?; + if cache.len() >= self.prune_body_types_at.get() { + cache.retain(|_, (cached, _)| cached.strong_count() > 0); + self.prune_body_types_at.set(2 * cache.len().max(32)); + } + cache.insert(shape_id.to_string(), (Arc::downgrade(brep), kind)); + Ok(kind) + } +} diff --git a/main/opengeometry/src/bindings/world_graph/mod.rs b/main/opengeometry/src/bindings/world_graph/mod.rs index b33455b0..2da8477f 100644 --- a/main/opengeometry/src/bindings/world_graph/mod.rs +++ b/main/opengeometry/src/bindings/world_graph/mod.rs @@ -8,10 +8,12 @@ mod rebuild; mod shapes; use crate::bindings::{errors, panic, params}; -use crate::brep::Accuracy; +use crate::brep::{Accuracy, BodyType, BrepEnvelope}; use crate::world_graph::{ChangeSet, CopyOptions, CreateOptions, GraphError, MarkId, WorldGraph}; use serde::Serialize; +use std::cell::{Cell, RefCell}; use std::collections::BTreeMap; +use std::sync::Weak; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; @@ -49,6 +51,9 @@ pub struct OGWorldGraph { inner: WorldGraph, marks: BTreeMap, next_mark: u32, + body_types: RefCell, BodyType)>>, + prune_body_types_at: Cell, + handles: RefCell>, } #[wasm_bindgen] @@ -62,6 +67,9 @@ impl OGWorldGraph { inner, marks: BTreeMap::new(), next_mark: 0, + body_types: RefCell::new(BTreeMap::new()), + prune_body_types_at: Cell::new(64), + handles: RefCell::new(BTreeMap::new()), }) } } diff --git a/main/opengeometry/src/geom2d/boolean2d.rs b/main/opengeometry/src/geom2d/boolean2d.rs index c231da34..98cf368d 100644 --- a/main/opengeometry/src/geom2d/boolean2d.rs +++ b/main/opengeometry/src/geom2d/boolean2d.rs @@ -1,6 +1,6 @@ use super::poly2d::{ - cross2, dedupe_ring, dot2, point_on_edge_t, ring_inside2, segment_cross_t2, signed_area2, - winding_number2, Edge2, Pt2, + cross2, crossing_margin, dedupe_ring, dot2, edge_box, nearby_boxes, point_on_edge_t, + ring_inside2, segment_cross_t2, signed_area2, winding_number2, Edge2, Pt2, }; use crate::brep::GeometryError; @@ -102,17 +102,29 @@ pub(crate) fn boolean_oriented_regions( let mut contours = Vec::with_capacity(a.len() + b.len()); contours.extend_from_slice(a); contours.extend_from_slice(b); - winding_regions_by(&contours, eps, |point| { - let in_a = a + let spans = |rings: &[Vec]| { + rings .iter() - .map(|ring| winding_number2(point, ring)) - .sum::() - != 0; - let in_b = b + .map(|ring| { + ring.iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), point| { + (lo.min(point.z), hi.max(point.z)) + }) + }) + .collect::>() + }; + let (a_spans, b_spans) = (spans(a), spans(b)); + let winding = |point: Pt2, rings: &[Vec], spans: &[(f64, f64)]| { + rings .iter() - .map(|ring| winding_number2(point, ring)) + .zip(spans) + .filter(|(_, &(lo, hi))| lo <= point.z && point.z < hi) + .map(|(ring, _)| winding_number2(point, ring)) .sum::() - != 0; + }; + winding_regions_by(&contours, eps, |point| { + let in_a = winding(point, a, &a_spans) != 0; + let in_b = winding(point, b, &b_spans) != 0; match operation { PlanarBooleanOp::Union => in_a || in_b, PlanarBooleanOp::Intersection => in_a && in_b, @@ -159,21 +171,21 @@ pub(crate) fn regions_from_edges_by( } fn subdivided_edges(edges: &[Edge2], eps: f64) -> Vec { - let verts: Vec = edges.iter().flat_map(|&(a, b)| [a, b]).collect(); + let boxes = edges.iter().map(|&edge| edge_box(edge)).collect::>(); + let near = nearby_boxes(&boxes, crossing_margin(edges.iter().copied(), eps)); let mut split: Vec = Vec::new(); for (i, &(ea, eb)) in edges.iter().enumerate() { let rx = eb.x - ea.x; let rz = eb.z - ea.z; let mut ts: Vec = vec![0.0, 1.0]; - for (j, &(fa, fb)) in edges.iter().enumerate() { - if i != j { - if let Some((_, ta, _)) = segment_cross_t2(ea, eb, fa, fb, eps) { - ts.push(ta); - } + for j in near.of(i) { + let (fa, fb) = edges[j]; + if let Some((_, ta, _)) = segment_cross_t2(ea, eb, fa, fb, eps) { + ts.push(ta); } } - for &v in &verts { + for v in near.of(i).flat_map(|j| [edges[j].0, edges[j].1]) { if let Some(t) = point_on_edge_t(ea, eb, v, eps) { ts.push(t); } @@ -200,7 +212,10 @@ fn subdivided_edges(edges: &[Edge2], eps: f64) -> Vec { fn boundary_edges(split: &[Edge2], eps: f64, filled: impl Fn(Pt2) -> bool) -> Vec { let mut boundary: Vec = Vec::new(); - for &(a, b) in split { + let boxes = split.iter().map(|&edge| edge_box(edge)).collect::>(); + let most = 1.0e-4; + let near = nearby_boxes(&boxes, 4.0 * most); + for (index, &(a, b)) in split.iter().enumerate() { let dx = b.x - a.x; let dz = b.z - a.z; let len = dx.hypot(dz); @@ -211,7 +226,10 @@ fn boundary_edges(split: &[Edge2], eps: f64, filled: impl Fn(Pt2) -> bool) -> Ve let nz = dx / len; let mx = (a.x + b.x) / 2.0; let mz = (a.z + b.z) / 2.0; - let probe = probe_reach(Pt2::new(mx, mz), len, 1.0e-4, split, eps); + let mut nearby = near.of(index).collect::>(); + nearby.insert(nearby.partition_point(|&other| other < index), index); + let nearby = nearby.iter().map(|&other| split[other]).collect::>(); + let probe = probe_reach(Pt2::new(mx, mz), len, most, &nearby, eps); let left = filled(Pt2::new(mx + nx * probe, mz + nz * probe)); let right = filled(Pt2::new(mx - nx * probe, mz - nz * probe)); if left == right { diff --git a/main/opengeometry/src/geom2d/mod.rs b/main/opengeometry/src/geom2d/mod.rs index d54b1c1e..5b1343ff 100644 --- a/main/opengeometry/src/geom2d/mod.rs +++ b/main/opengeometry/src/geom2d/mod.rs @@ -15,5 +15,6 @@ pub(super) use offset::{ offset_ring_variable, oriented_ring, pinched, OffsetError, OffsetHole, OffsetOptions, }; pub(super) use poly2d::{ - point_in_ring2, segments_cross2, self_intersects2, signed_area2, winding_number2, Pt2, + crossing_margin, nearby_boxes, point_in_ring2, segments_cross2, self_intersects2, signed_area2, + winding_number2, Nearby, Pt2, }; diff --git a/main/opengeometry/src/geom2d/poly2d.rs b/main/opengeometry/src/geom2d/poly2d.rs index ba2cefaa..0acda656 100644 --- a/main/opengeometry/src/geom2d/poly2d.rs +++ b/main/opengeometry/src/geom2d/poly2d.rs @@ -161,7 +161,7 @@ pub(crate) fn winding_number2(p: Pt2, ring: &[Pt2]) -> i32 { wn } -pub(super) type Edge2 = (Pt2, Pt2); +pub(crate) type Edge2 = (Pt2, Pt2); pub(super) fn point_on_edge_t(a: Pt2, b: Pt2, p: Pt2, eps: f64) -> Option { let rx = b.x - a.x; @@ -180,3 +180,83 @@ pub(super) fn point_on_edge_t(a: Pt2, b: Pt2, p: Pt2, eps: f64) -> Option { None } } + +pub(crate) struct Nearby { + pub(super) lists: Option>>, + count: usize, +} + +impl Nearby { + pub(crate) fn of(&self, index: usize) -> impl Iterator + '_ { + let (listed, all) = match &self.lists { + Some(lists) => (lists[index].as_slice(), 0..0), + None => (&[][..], 0..self.count), + }; + listed + .iter() + .copied() + .chain(all.filter(move |&other| other != index)) + } +} + +pub(crate) fn nearby_boxes(boxes: &[[f64; 4]], margin: f64) -> Nearby { + Nearby { + lists: margin + .is_finite() + .then(|| swept_boxes(boxes, margin)) + .flatten(), + count: boxes.len(), + } +} + +fn swept_boxes(boxes: &[[f64; 4]], margin: f64) -> Option>> { + let budget = 64 * boxes.len() + 4096; + let mut pairs = 0; + let mut order = (0..boxes.len()).collect::>(); + order.sort_by(|&first, &second| boxes[first][0].total_cmp(&boxes[second][0])); + let mut active: Vec = Vec::new(); + let mut near = vec![Vec::new(); boxes.len()]; + for index in order { + let [lo_x, _, lo_z, hi_z] = boxes[index]; + active.retain(|&other| boxes[other][1] + margin >= lo_x); + for &other in &active { + if boxes[other][2] <= hi_z + margin && lo_z <= boxes[other][3] + margin { + pairs += 2; + if pairs > budget { + return None; + } + near[index].push(other); + near[other].push(index); + } + } + active.push(index); + } + for list in &mut near { + list.sort_unstable(); + } + Some(near) +} + +pub(super) fn edge_box((a, b): Edge2) -> [f64; 4] { + [a.x.min(b.x), a.x.max(b.x), a.z.min(b.z), a.z.max(b.z)] +} + +pub(crate) fn crossing_margin(edges: impl Iterator, eps: f64) -> f64 { + let (length, extent) = edges.fold((0.0_f64, 0.0_f64), |(length, extent), (a, b)| { + ( + length.max((b.x - a.x).abs() + (b.z - a.z).abs()), + extent + .max(a.x.abs()) + .max(a.z.abs()) + .max(b.x.abs()) + .max(b.z.abs()), + ) + }); + let margin = eps + + 64.0 * f64::EPSILON * (length * length * (4.0 * extent + length) / eps + length + extent); + if margin.is_finite() && 16.0 * f64::EPSILON * length * length < 0.5 * eps { + margin + } else { + f64::INFINITY + } +} diff --git a/main/opengeometry/src/geom2d/tests/boolean2d.rs b/main/opengeometry/src/geom2d/tests/boolean2d.rs index 06109028..f8234974 100644 --- a/main/opengeometry/src/geom2d/tests/boolean2d.rs +++ b/main/opengeometry/src/geom2d/tests/boolean2d.rs @@ -2,7 +2,7 @@ use super::{area, edges_of, hole, p, rectangle, regions, union, winding_regions, use crate::geom2d::boolean2d::{ boolean_oriented_regions, regions_from_edges_by, PlanarBooleanOp, RegionBooleanError, }; -use crate::geom2d::poly2d::{self_intersects2, signed_area2, Pt2}; +use crate::geom2d::poly2d::{crossing_margin, nearby_boxes, self_intersects2, signed_area2, Pt2}; #[test] fn open_section_segment_partitions_a_classified_face() { @@ -309,3 +309,61 @@ fn a_hole_that_no_region_contains_is_an_error() { Some(RegionBooleanError::OrphanHole) ); } + +#[test] +fn nearby_boxes_lists_exactly_the_pairs_within_the_margin() { + let mut boxes = (0..30) + .flat_map(|row| { + (0..40).map(move |column| { + let (x, z) = (f64::from(column) * 0.25, f64::from(row) * 0.25); + [x, x + 0.125, z, z + 0.125] + }) + }) + .collect::>(); + boxes.push([0.0, 10.0, 0.0, 0.0625]); + boxes.push([3.0, 3.0625, -2.0, 8.0]); + let margin = 0.125; + let near = nearby_boxes(&boxes, margin); + let gap = |a: [f64; 4], b: [f64; 4]| { + (b[0] - a[1]) + .max(a[0] - b[1]) + .max(b[2] - a[3]) + .max(a[2] - b[3]) + }; + for index in 0..boxes.len() { + let expected = (0..boxes.len()) + .filter(|&other| other != index && gap(boxes[index], boxes[other]) <= margin) + .collect::>(); + assert_eq!(near.of(index).collect::>(), expected, "box {index}"); + } + assert_eq!( + (0..boxes.len()) + .map(|index| near.of(index).count()) + .sum::(), + 9_386 + ); + assert!(near.lists.is_some()); +} + +#[test] +fn a_cutter_a_thousand_units_wide_visits_every_pair_without_listing_them() { + let host = vec![rectangle(0.0, 0.0, 2_000.0, 2_000.0)]; + let cutter = vec![rectangle(1_000.0, -500.0, 3_000.0, 1_000.0)]; + let edges = edges_of(&host[0]) + .into_iter() + .chain(edges_of(&cutter[0])) + .collect::>(); + let margin = crossing_margin(edges.iter().copied(), 1.0e-9); + assert_eq!(margin, f64::INFINITY); + assert!(nearby_boxes(&[[0.0, 1.0, 0.0, 1.0]; 3], margin) + .lists + .is_none()); + assert!(nearby_boxes(&[[0.0, 1.0, 0.0, 1.0]; 200], 0.0) + .lists + .is_none()); + let regions = + boolean_oriented_regions(&host, &cutter, PlanarBooleanOp::Subtraction, 1.0e-9).unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].outer.len(), 6); + assert_eq!(area(®ions), 3_000_000.0); +} diff --git a/main/opengeometry/src/operations/modifying/boolean/assembly/append.rs b/main/opengeometry/src/operations/modifying/boolean/assembly/append.rs index dfbf2b41..5bdc5702 100644 --- a/main/opengeometry/src/operations/modifying/boolean/assembly/append.rs +++ b/main/opengeometry/src/operations/modifying/boolean/assembly/append.rs @@ -7,6 +7,7 @@ use crate::operations::modifying::boolean::operands::CylinderInput; use crate::operations::modifying::boolean::types::{ BooleanOp, BooleanReport, BooleanResult, FaceMapping, }; +use std::collections::HashMap; struct IdOffsets { surface: u32, @@ -250,6 +251,22 @@ pub(crate) fn analytic_face_mappings<'a>( out: &BrepEnvelope, inputs: impl IntoIterator, ) -> Vec { + let mut results: HashMap<(&str, &str, &str, u32), Vec> = HashMap::new(); + for result in &out.topology.faces { + for candidate in &result.provenance.sources { + let faces = results + .entry(( + &candidate.entity, + &candidate.body, + &candidate.key, + candidate.face, + )) + .or_default(); + if faces.last() != Some(&result.id) { + faces.push(result.id); + } + } + } inputs .into_iter() .flat_map(|input| { @@ -260,20 +277,10 @@ pub(crate) fn analytic_face_mappings<'a>( key: face.key.clone(), face: face.id, }; - let result_faces = out - .topology - .faces - .iter() - .filter(|result| { - result.provenance.sources.iter().any(|candidate| { - candidate.entity == source.entity - && candidate.body == source.body - && candidate.key == source.key - && candidate.face == source.face - }) - }) - .map(|result| result.id) - .collect(); + let result_faces = results + .get(&(&*source.entity, &*source.body, &*source.key, source.face)) + .cloned() + .unwrap_or_default(); FaceMapping { source, result_faces, diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/facets.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/facets.rs index 5bb3e510..718e963a 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/facets.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/facets.rs @@ -5,15 +5,16 @@ use crate::brep::{ }; use crate::math::{cross, dot, norm, sub, Interval, Point3}; use crate::operations::modifying::boolean::assembly::{ - add_welded_vertex, analytic_face_mappings, assign_shell_faces, enclosing_solid, + analytic_face_mappings, assign_shell_faces, enclosing_solid, }; use crate::operations::modifying::boolean::types::{BooleanOp, BooleanReport, BooleanResult}; use crate::query::face_contains_uv; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; pub(super) struct PlanarFacets { builder: Builder, vertices: Vec<(Point3, u32)>, + cells: HashMap<[i64; 3], Vec>, edges: BTreeMap<(u32, u32), u32>, pub(super) accuracy: Accuracy, } @@ -66,13 +67,37 @@ impl PlanarFacets { Ok(Self { builder: Builder::new(id, accuracy)?, vertices: Vec::new(), + cells: HashMap::new(), edges: BTreeMap::new(), accuracy, }) } fn vertex(&mut self, point: Point3) -> u32 { - add_welded_vertex(&mut self.builder, &mut self.vertices, point, self.accuracy) + let reach = self.accuracy.geometric / 4.0; + let cell = point.map(|value| (value / (2.0 * reach)).floor() as i64); + let found = (0..27) + .filter_map(|step: i64| { + self.cells.get(&[ + cell[0].saturating_add(step % 3 - 1), + cell[1].saturating_add(step / 3 % 3 - 1), + cell[2].saturating_add(step / 9 - 1), + ]) + }) + .flatten() + .copied() + .filter(|&index| norm(sub(self.vertices[index].0, point)) <= reach) + .min(); + if let Some(index) = found { + return self.vertices[index].1; + } + let id = self.builder.vertex(point); + self.cells + .entry(cell) + .or_default() + .push(self.vertices.len()); + self.vertices.push((point, id)); + id } fn uses(&mut self, ring: &[Point3], frame: Frame3) -> Result, GeometryError> { diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs index 3084b3b4..60ab6255 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs @@ -9,10 +9,10 @@ use crate::geom2d::{ use crate::math::{dot, scale, sub, Point3}; use crate::operations::modifying::boolean::operands::brep_face_source; use crate::operations::modifying::boolean::types::BooleanResult; -use crate::query::{classify_point, PointClassification}; +use crate::query::{Classifier, PointClassification}; use sections::{ - convex_faces, convex_section_polygon, planar_faces, planar_section_segments, split_planar_ring, - PlanarFace, PlanarFacet, + convex_faces, convex_section_polygon, planar_faces, planar_section_segments, + ring_vertex_candidates, split_planar_ring, PlanarFace, PlanarFacet, }; use std::cell::RefCell; @@ -43,8 +43,9 @@ pub(crate) fn subtract_planar_polyhedra( pieces.push(host_piece(host, face, ®ion)); } } + let classifier = Classifier::new(host); for face in &cutter_faces { - let regions = cutter_face_regions(host, &host_faces, face, accuracy)?; + let regions = cutter_face_regions(host, &classifier, &host_faces, face, accuracy)?; let reversed = Frame3 { origin: face.frame.origin, x: face.frame.x, @@ -68,14 +69,22 @@ pub(crate) fn subtract_planar_polyhedra( .copied() }) .collect::>(); + let tolerance = accuracy.geometric / 4.0; + let rings = pieces + .iter() + .flat_map(|piece| std::iter::once(&piece.outer).chain(&piece.holes)) + .map(Vec::as_slice) + .collect::>(); + let near = ring_vertex_candidates(&rings, &vertices, tolerance); + let mut offset = 0; + let mut split = |ring: &[Point3]| { + offset += ring.len(); + split_planar_ring(ring, &vertices, &near, offset - ring.len(), tolerance) + }; let mut facets = PlanarFacets::new(id, accuracy)?; for piece in pieces { - let outer = split_planar_ring(&piece.outer, &vertices, accuracy.geometric / 4.0); - let holes = piece - .holes - .iter() - .map(|ring| split_planar_ring(ring, &vertices, accuracy.geometric / 4.0)) - .collect(); + let outer = split(&piece.outer); + let holes = piece.holes.iter().map(|ring| split(ring)).collect(); facets.face(&piece.key, piece.frame, outer, holes, piece.provenance)?; } let mut result = finish(facets, host, cutter)?; @@ -84,14 +93,15 @@ pub(crate) fn subtract_planar_polyhedra( } fn check_not_nested(host: &BrepEnvelope, cutter: &BrepEnvelope) -> Result<(), GeometryError> { + let (in_host, in_cutter) = (Classifier::new(host), Classifier::new(cutter)); if cutter.topology.vertices.iter().all(|vertex| { matches!( - classify_point(host, vertex.position), + in_host.classify(vertex.position), Ok(PointClassification::Inside) ) }) || host.topology.vertices.iter().all(|vertex| { matches!( - classify_point(cutter, vertex.position), + in_cutter.classify(vertex.position), Ok(PointClassification::Inside) ) }) { @@ -149,6 +159,7 @@ fn classified_host_regions( ); } let failure = RefCell::new(None); + let classifier = Classifier::new(cutter); let regions = regions_from_edges_by(&edges, accuracy.intersection, |point| { if base .iter() @@ -159,18 +170,15 @@ fn classified_host_regions( return false; } let position = face.frame.point([point.x, point.z, 0.0]); - let classification = classify_point(cutter, position); + let classification = classifier.classify(position); let classification = match classification { - Ok(PointClassification::Boundary) => classify_point( - cutter, - sub( - position, - scale( - face.frame.z, - accuracy.geometric.max(accuracy.intersection) * 4.0, - ), + Ok(PointClassification::Boundary) => classifier.classify(sub( + position, + scale( + face.frame.z, + accuracy.geometric.max(accuracy.intersection) * 4.0, ), - ), + )), other => other, }; match classification { @@ -236,6 +244,7 @@ fn host_piece(host: &BrepEnvelope, face: &PlanarFace, region: &RingRegion) -> Pl fn cutter_face_regions( host: &BrepEnvelope, + classifier: &Classifier, host_faces: &[PlanarFace], face: &PlanarFace, accuracy: Accuracy, @@ -266,7 +275,7 @@ fn cutter_face_regions( { return false; } - match classify_point(host, face.frame.point([point.x, point.z, 0.0])) { + match classifier.classify(face.frame.point([point.x, point.z, 0.0])) { Ok(PointClassification::Inside) | Ok(PointClassification::Boundary) => true, Ok(PointClassification::Outside) => false, Ok(PointClassification::Unknown) => { diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/sections.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/sections.rs index bdbd908e..8f7ccea3 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/sections.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/sections.rs @@ -2,7 +2,7 @@ use crate::brep::{ unit, BrepEnvelope, CurveGeometry, FaceProvenance, Frame3, GeometryError, Orientation, SurfaceGeometry, }; -use crate::geom2d::Pt2; +use crate::geom2d::{nearby_boxes, Nearby, Pt2}; use crate::math::{cross, dot, norm, scale, sub, Point3}; use crate::operations::modifying::boolean::handlers::planar::facets::{coverage, loop_positions}; use crate::query::face_contains_uv; @@ -324,9 +324,49 @@ fn clip_convex_face(polygon: &[Point3], planes: &[PlanarFace], tolerance: f64) - } } +pub(super) fn ring_vertex_candidates( + rings: &[&[Point3]], + vertices: &[Point3], + tolerance: f64, +) -> Nearby { + let extent = |axis: usize| { + let (lo, hi) = vertices + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), point| { + (lo.min(point[axis]), hi.max(point[axis])) + }); + hi - lo + }; + let mut axes = [0, 1, 2]; + axes.sort_by(|&first, &second| extent(second).total_cmp(&extent(first))); + let [a, b, _] = axes; + let reach = vertices + .iter() + .flatten() + .fold(0.0_f64, |most, value| most.max(value.abs())); + let boxes = vertices + .iter() + .map(|point| [point[a], point[a], point[b], point[b]]) + .chain(rings.iter().flat_map(|ring| { + (0..ring.len()).map(|index| { + let (from, to) = (ring[index], ring[(index + 1) % ring.len()]); + [ + from[a].min(to[a]), + from[a].max(to[a]), + from[b].min(to[b]), + from[b].max(to[b]), + ] + }) + })) + .collect::>(); + nearby_boxes(&boxes, 2.0 * tolerance + 16.0 * f64::EPSILON * reach) +} + pub(super) fn split_planar_ring( ring: &[Point3], vertices: &[Point3], + near: &Nearby, + first_edge: usize, tolerance: f64, ) -> Vec { let mut split = Vec::new(); @@ -340,7 +380,11 @@ pub(super) fn split_planar_ring( } split.push(from); let mut interior = Vec::new(); - for &point in vertices { + for point in near + .of(vertices.len() + first_edge + index) + .filter(|&vertex| vertex < vertices.len()) + .map(|vertex| vertices[vertex]) + { let offset = sub(point, from); let fraction = dot(offset, delta) / (length * length); if fraction > tolerance / length diff --git a/main/opengeometry/src/primitives/internal/linear_extrusion.rs b/main/opengeometry/src/primitives/internal/linear_extrusion.rs index 4fb7a05b..5f8324a7 100644 --- a/main/opengeometry/src/primitives/internal/linear_extrusion.rs +++ b/main/opengeometry/src/primitives/internal/linear_extrusion.rs @@ -3,6 +3,7 @@ use crate::brep::{ dimensions, padded_uv_bounds, plane_boundary, unit, Accuracy, BrepEnvelope, Builder, CurveGeometry, Frame3, GeometryError, Orientation, SurfaceGeometry, Use, }; +use crate::geom2d::{crossing_margin, nearby_boxes, Nearby, Pt2}; use crate::math::{norm, scale, sub, Interval}; use crate::primitives::profile::{ loops_touch, orient_profile_loop, profile_point_inside, validate_profile_loop, @@ -33,8 +34,9 @@ pub fn linear_extrusion( ))); } } + let near = hole_neighbours(&holes, accuracy.geometric); for first in 0..holes.len() { - for second in (first + 1)..holes.len() { + for second in near.of(first).filter(|&second| second > first) { if loops_touch(&holes[first], &holes[second], accuracy.geometric) || profile_point_inside(holes[first][0], &holes[second]) || profile_point_inside(holes[second][0], &holes[first]) @@ -259,3 +261,27 @@ fn add_side_faces( } Ok(()) } + +fn hole_neighbours(holes: &[Vec<[f64; 2]>], tolerance: f64) -> Nearby { + let boxes = holes + .iter() + .map(|hole| { + let (xs, ys) = ( + hole.iter().map(|point| point[0]), + hole.iter().map(|point| point[1]), + ); + [ + xs.clone().fold(f64::INFINITY, f64::min), + xs.fold(f64::NEG_INFINITY, f64::max), + ys.clone().fold(f64::INFINITY, f64::min), + ys.fold(f64::NEG_INFINITY, f64::max), + ] + }) + .collect::>(); + let edges = holes.iter().flat_map(|hole| { + hole.iter() + .zip(hole.iter().cycle().skip(1)) + .map(|(from, to)| (Pt2::new(from[0], from[1]), Pt2::new(to[0], to[1]))) + }); + nearby_boxes(&boxes, crossing_margin(edges, tolerance)) +} diff --git a/main/opengeometry/src/query/classify.rs b/main/opengeometry/src/query/classify.rs index a3d2a575..8ea6b431 100644 --- a/main/opengeometry/src/query/classify.rs +++ b/main/opengeometry/src/query/classify.rs @@ -2,17 +2,46 @@ use super::classification::PointClassification; use super::face_trim::face_contains_uv; use super::ray_roots::{ray_box_domain, support_roots}; use crate::brep::{ - unit, BrepEnvelope, Face, GeometryError, SolidRegion, Surface, SurfaceGeometry, UV, + unit, BrepEnvelope, Face, GeometryError, PatchBounds, SolidRegion, Surface, SurfaceGeometry, UV, }; use crate::math::{dot, norm, scale, sub, Point3}; +use std::cell::Cell; + +pub(crate) struct Classifier<'a> { + brep: &'a BrepEnvelope, + bounds: Cell>>, +} + +impl<'a> Classifier<'a> { + pub(crate) fn new(brep: &'a BrepEnvelope) -> Self { + Self { + brep, + bounds: Cell::new(None), + } + } + + pub(crate) fn classify(&self, point: Point3) -> Result { + classify_validated(self, point) + } + + fn bounds(&self) -> Result, GeometryError> { + if let Some(bounds) = self.bounds.get() { + return Ok(bounds); + } + let bounds = self.brep.bounds_unchecked()?; + self.bounds.set(Some(bounds)); + Ok(bounds) + } +} fn classify_with_ray<'a>( - brep: &BrepEnvelope, + classifier: &Classifier, point: Point3, direction: Point3, faces: impl Iterator, ) -> Result { - let Some(domain) = ray_box_domain(brep, point, direction)? else { + let brep = classifier.brep; + let Some(domain) = ray_box_domain(classifier.bounds()?, point, direction)? else { return Ok(PointClassification::Outside); }; let mut hits: Vec<(f64, Point3)> = Vec::new(); @@ -83,6 +112,14 @@ pub(crate) fn classify_point_validated( brep: &BrepEnvelope, point: Point3, ) -> Result { + classify_validated(&Classifier::new(brep), point) +} + +fn classify_validated( + classifier: &Classifier, + point: Point3, +) -> Result { + let brep = classifier.brep; if point.into_iter().any(|value| !value.is_finite()) { return Err(GeometryError::InvalidGeometry( "point classification requires finite coordinates".into(), @@ -90,7 +127,7 @@ pub(crate) fn classify_point_validated( } let mut answer = PointClassification::Outside; for solid in &brep.solids { - match classify_point_in_solid(brep, solid, point)? { + match classify_point_in_solid(classifier, solid, point)? { PointClassification::Inside => return Ok(PointClassification::Inside), PointClassification::Boundary => answer = PointClassification::Boundary, PointClassification::Unknown if answer == PointClassification::Outside => { @@ -103,17 +140,17 @@ pub(crate) fn classify_point_validated( } fn classify_point_in_solid( - brep: &BrepEnvelope, + classifier: &Classifier, solid: &SolidRegion, point: Point3, ) -> Result { - let shell_faces = |shell: u32| &brep.topology.shells[shell as usize].faces; - let outer = classify_point_in_shell(brep, shell_faces(solid.outer_shell), point)?; + let shell_faces = |shell: u32| &classifier.brep.topology.shells[shell as usize].faces; + let outer = classify_shell(classifier, shell_faces(solid.outer_shell), point)?; if outer != PointClassification::Inside { return Ok(outer); } for cavity in &solid.cavity_shells { - match classify_point_in_shell(brep, shell_faces(*cavity), point)? { + match classify_shell(classifier, shell_faces(*cavity), point)? { PointClassification::Outside => {} PointClassification::Inside => return Ok(PointClassification::Outside), other => return Ok(other), @@ -127,6 +164,15 @@ pub(crate) fn classify_point_in_shell( faces: &[u32], point: Point3, ) -> Result { + classify_shell(&Classifier::new(brep), faces, point) +} + +fn classify_shell( + classifier: &Classifier, + faces: &[u32], + point: Point3, +) -> Result { + let brep = classifier.brep; for face in faces { if on_face(brep, &brep.topology.faces[*face as usize], point)? { return Ok(PointClassification::Boundary); @@ -139,7 +185,7 @@ pub(crate) fn classify_point_in_shell( ]; for direction in directions { let result = classify_with_ray( - brep, + classifier, point, direction, faces diff --git a/main/opengeometry/src/query/mod.rs b/main/opengeometry/src/query/mod.rs index 88746abc..9eedc813 100644 --- a/main/opengeometry/src/query/mod.rs +++ b/main/opengeometry/src/query/mod.rs @@ -9,5 +9,6 @@ mod tests; pub use classification::PointClassification; pub use classify::classify_point; pub(super) use classify::classify_point_in_shell; +pub(crate) use classify::Classifier; pub(super) use face_trim::face_contains_uv; pub use measure::{validity, Validity}; diff --git a/main/opengeometry/src/query/ray_roots.rs b/main/opengeometry/src/query/ray_roots.rs index 4fb9de5a..942d533f 100644 --- a/main/opengeometry/src/query/ray_roots.rs +++ b/main/opengeometry/src/query/ray_roots.rs @@ -1,4 +1,4 @@ -use crate::brep::{BrepEnvelope, GeometryError, SurfaceGeometry}; +use crate::brep::{GeometryError, PatchBounds, SurfaceGeometry}; use crate::math::{dot, isolate_candidates, quadratic, Interval, Point3, QuadraticRoots}; pub(super) fn support_roots( @@ -145,11 +145,11 @@ fn polynomial_value(coefficients: &[f64], value: f64) -> f64 { } pub(super) fn ray_box_domain( - brep: &BrepEnvelope, + bounds: Option, origin: Point3, direction: Point3, ) -> Result, GeometryError> { - let Some(bounds) = brep.bounds_unchecked()? else { + let Some(bounds) = bounds else { return Ok(None); }; let mut lo: f64 = 0.0; diff --git a/main/scripts/test/bindings.test.mjs b/main/scripts/test/bindings.test.mjs index 7621d020..ea9f4544 100644 --- a/main/scripts/test/bindings.test.mjs +++ b/main/scripts/test/bindings.test.mjs @@ -112,6 +112,20 @@ assert.throws( () => GRAPH.nodeByHandle(INFO.handle, INFO.generation), (error) => JSON.parse(String(error)).code === 'Disposed', ); +GRAPH.createPrimitive(ENCODE({ kind: 'Cuboid', width: 2, height: 2, depth: 2 }), ENCODE({ ogId: 'host' })); +GRAPH.createPrimitive(ENCODE(UNIT_CUBE), ENCODE({ ogId: 'tool' })); +const HOST = JSON.parse(GRAPH.node('host')); +GRAPH.operate('host', ENCODE('Subtract'), ENCODE(['tool']), ENCODE('Node')); +const OPERATED = JSON.parse(GRAPH.nodeByHandle(HOST.handle, HOST.generation)); +assert.equal(OPERATED.bodyType, 'Solid'); +assert.equal(OPERATED.shapeRevision, HOST.shapeRevision + 1); +GRAPH.createPrimitive(ENCODE(UNIT_CUBE), ENCODE({ ogId: 'cube' })); +const REBORN = JSON.parse(GRAPH.node('cube')); +assert.throws( + () => GRAPH.nodeByHandle(INFO.handle, INFO.generation), + (error) => JSON.parse(String(error)).code === 'Disposed', +); +assert.equal(JSON.parse(GRAPH.nodeByHandle(REBORN.handle, REBORN.generation)).ogId, 'cube'); TESSELLATOR.free(); GRAPH.free(); console.log('WASM bindings passed');