From bc72a5af8381a9d074261ddb236d0c0bd7f4fcce Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 15:54:49 +0200 Subject: [PATCH 01/29] test: a trimmed curved face tessellates to the same arrays each time Two fresh tessellations of one body must give the same arrays. For a curved face whose trim needs refinement they do not: the annular sector with a round opening gives 0 equal runs of 20 at both deflections, so the first test fails on this commit. The sector with a raised cutout is a second trimmed curved body; it is stable already and stays as a control. The second test pins the sorted set of triangles of both bodies, so the fix that follows can change the order of the vertices only. It passes on this commit. The constants are compared on aarch64-apple-darwin only, where they were recorded: the last bit of sine and cosine can differ between targets. --- .../opengeometry/src/analytic/tessellation.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/main/opengeometry/src/analytic/tessellation.rs b/main/opengeometry/src/analytic/tessellation.rs index 4798e3c..cc430ef 100644 --- a/main/opengeometry/src/analytic/tessellation.rs +++ b/main/opengeometry/src/analytic/tessellation.rs @@ -2002,4 +2002,144 @@ mod tests { assert_eq!(cache.len(), 3); assert!(tessellate(&b, 0.001, 1).is_err()); } + fn trimmed_curved_bodies() -> [BrepEnvelope; 2] { + let accuracy = Accuracy { + geometric: 6.3e-8, + intersection: 1.575e-8, + tessellation: 0.01, + exchange: 1e-5, + }; + let sector = primitives::annular_sector_extrusion( + "sector".into(), + Frame3 { + origin: [0.0, 0.0, 0.0], + x: [1.0, 0.0, 0.0], + y: [0.0, 0.0, -1.0], + z: [0.0, 1.0, 0.0], + }, + 3.0, + 0.3, + 3.0, + 0.8, + -1.6, + accuracy, + ) + .unwrap(); + let round = primitives::cylinder( + "round".into(), + Frame3 { + origin: [2.7, 1.5, 0.0], + x: [0.0, 0.0, -1.0], + y: [0.0, 1.0, 0.0], + z: [1.0, 0.0, 0.0], + }, + 0.5, + 0.6, + accuracy, + ) + .unwrap(); + let round_opening = crate::analytic::booleans::boolean_brep( + §or, + &round, + crate::analytic::booleans::BooleanOp::Subtraction, + "sector with a round opening".into(), + ) + .unwrap() + .brep; + let raised_cutout = primitives::annular_sector_extrusion_with_openings( + "sector with a raised cutout".into(), + Frame3::IDENTITY, + 3.0, + 0.3, + 3.0, + 0.0, + 2.0, + vec![primitives::AnnularSectorOpening { + id: "raised_cutout".into(), + angle: 1.0, + width: 0.8, + bottom: 0.9, + height: 1.2, + }], + accuracy, + ) + .unwrap(); + [round_opening, raised_cutout] + } + fn array_bits(mesh: &Tessellation) -> (Vec, Vec, Vec, [&Vec; 3]) { + ( + mesh.positions.iter().map(|v| v.to_bits()).collect(), + mesh.normals.iter().map(|v| v.to_bits()).collect(), + mesh.outline_positions.iter().map(|v| v.to_bits()).collect(), + [ + &mesh.indices, + &mesh.triangle_face_ids, + &mesh.outline_edge_ids, + ], + ) + } + fn triangle_set_hash(mesh: &Tessellation) -> u64 { + let corner = |id: u32| { + let at = id as usize * 3; + let position = mesh.positions[at..at + 3].iter().map(|v| v.to_bits()); + let normal = mesh.normals[at..at + 3] + .iter() + .map(|v| u64::from(v.to_bits())); + position.chain(normal).collect::>() + }; + let mut triangles = mesh + .indices + .chunks_exact(3) + .zip(&mesh.triangle_face_ids) + .map(|(ids, &face)| { + let mut corners = [corner(ids[0]), corner(ids[1]), corner(ids[2])]; + let first = (0..3).min_by_key(|&i| &corners[i]).unwrap_or(0); + corners.rotate_left(first); + (face, corners) + }) + .collect::>(); + triangles.sort(); + triangles + .iter() + .flat_map(|(face, corners)| { + std::iter::once(u64::from(*face)).chain(corners.iter().flatten().copied()) + }) + .flat_map(u64::to_le_bytes) + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) + } + #[test] + fn a_trimmed_curved_face_tessellates_to_the_same_arrays_each_time() { + let equal_runs = trimmed_curved_bodies().map(|body| { + [0.05, 0.01].map(|deflection| { + let first = tessellate(&body, deflection, 2_000_000).unwrap(); + (0..20) + .filter(|_| { + let again = tessellate(&body, deflection, 2_000_000).unwrap(); + array_bits(&first) == array_bits(&again) + }) + .count() + }) + }); + assert_eq!(equal_runs, [[20; 2]; 2]); + } + const TRIANGLE_SETS_ON_AARCH64_APPLE_DARWIN: [[u64; 2]; 2] = [ + [0xf0d9_a930_15e0_d334, 0x6617_beaa_d081_2100], + [0xcea2_8fc6_831d_672a, 0x8b42_bcc8_7643_6dbb], + ]; + #[test] + fn a_trimmed_curved_face_keeps_its_triangle_set() { + let hashes = trimmed_curved_bodies().map(|body| { + [0.05, 0.01].map(|deflection| { + triangle_set_hash(&tessellate(&body, deflection, 2_000_000).unwrap()) + }) + }); + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + assert_eq!( + hashes, TRIANGLE_SETS_ON_AARCH64_APPLE_DARWIN, + "{hashes:#018x?}" + ); + } + } } From ce2142fbaea3778a6293aca2437f27456adb3bc3 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 15:55:18 +0200 Subject: [PATCH 02/29] fix(tessellation): number the midpoints of a trimmed curved face in a fixed order The refinement of a trimmed curved face gave each new midpoint its vertex number in the iteration order of a hash set. That order follows the hash keys of the standard library, which change with every hash collection the process makes, so one body gave different positions, normals and indices from call to call (in WebAssembly: with the history of the instance). The split edges are now visited in sorted order. The set of triangles is the same as before; only the order of the vertices of such a face is now fixed. No other hash collection of the tessellation is iterated: the others are used for lookup only. --- main/opengeometry/src/analytic/tessellation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/opengeometry/src/analytic/tessellation.rs b/main/opengeometry/src/analytic/tessellation.rs index cc430ef..59264ef 100644 --- a/main/opengeometry/src/analytic/tessellation.rs +++ b/main/opengeometry/src/analytic/tessellation.rs @@ -5,7 +5,7 @@ use super::{ }; use crate::math::interval::Interval; use std::{ - collections::{HashMap, HashSet, VecDeque}, + collections::{BTreeSet, HashMap, HashSet, VecDeque}, hash::{DefaultHasher, Hasher}, io::Write, sync::Arc, @@ -1031,7 +1031,7 @@ fn general_curved_trimmed_face( .collect::>(); let surface = brep.geometry.surface(face.surface)?; for _ in 0..128 { - let mut split_edges = HashSet::new(); + let mut split_edges = BTreeSet::new(); for triangle in &triangles { for [a, b] in [ [triangle[0], triangle[1]], From 794489083dd88fbb5f6599d06df63343cd75457b Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 15:56:05 +0200 Subject: [PATCH 03/29] test: pin the triangles of six bodies on the text and object entries The wrapper is about to tessellate through the kernel object and the cache is about to be keyed by shape. Both must leave every array as it is, so this guard comes first and passes before those changes. For a cuboid, a linear extrusion with a hole, an annular sector with a round opening, a planar subtraction, a placed extrusion and a cuboid with another id and revision, at two deflections: a fresh tessellation of the body, a fresh tessellation of the body parsed from its JSON text, the text entry and the object entry give the same bits in all six arrays and in the achieved deflection, and each result carries the revision of its body. The renamed cuboid gives the hash of the first cuboid. The pinned FNV-1a hashes are compared on aarch64-apple-darwin only, where they were recorded, because the last bit of sine and cosine can differ between targets; the equal-entries checks run on every target. The guard is in the test module of wasm.rs because tessellate_cached is private to that file. --- main/opengeometry/src/analytic/wasm.rs | 203 +++++++++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/main/opengeometry/src/analytic/wasm.rs b/main/opengeometry/src/analytic/wasm.rs index 0844d45..0233bff 100644 --- a/main/opengeometry/src/analytic/wasm.rs +++ b/main/opengeometry/src/analytic/wasm.rs @@ -976,4 +976,207 @@ mod tests { assert!(tessellation_limit(Some(r#"{"unknown":1}"#.into())).is_err()); assert!(tessellation_limit(Some(r#"{"max_triangles":0}"#.into())).is_err()); } + + #[derive(Debug, PartialEq)] + struct MeshBits { + positions: Vec, + normals: Vec, + indices: Vec, + triangle_face_ids: Vec, + outline_positions: Vec, + outline_edge_ids: Vec, + achieved_deflection: u64, + } + impl MeshBits { + fn of_mesh(mesh: &Tessellation) -> Self { + Self { + positions: mesh.positions.iter().map(|v| v.to_bits()).collect(), + normals: mesh.normals.iter().map(|v| v.to_bits()).collect(), + indices: mesh.indices.clone(), + triangle_face_ids: mesh.triangle_face_ids.clone(), + outline_positions: mesh.outline_positions.iter().map(|v| v.to_bits()).collect(), + outline_edge_ids: mesh.outline_edge_ids.clone(), + achieved_deflection: mesh.achieved_deflection.to_bits(), + } + } + fn of_result(result: &OGAnalyticTessellation) -> Self { + Self { + positions: result.positions().iter().map(|v| v.to_bits()).collect(), + normals: result.normals().iter().map(|v| v.to_bits()).collect(), + indices: result.indices(), + triangle_face_ids: result.triangle_face_ids(), + outline_positions: result + .outline_positions() + .iter() + .map(|v| v.to_bits()) + .collect(), + outline_edge_ids: result.outline_edge_ids(), + achieved_deflection: result.achieved_deflection().to_bits(), + } + } + fn fnv1a(&self) -> u64 { + let wide = |values: &[u64]| -> Vec { + let length = (values.len() as u64).to_le_bytes(); + let bytes = values.iter().flat_map(|v| v.to_le_bytes()); + length.into_iter().chain(bytes).collect() + }; + let narrow = |values: &[u32]| -> Vec { + let length = (values.len() as u64).to_le_bytes(); + let bytes = values.iter().flat_map(|v| v.to_le_bytes()); + length.into_iter().chain(bytes).collect() + }; + [ + wide(&self.positions), + narrow(&self.normals), + narrow(&self.indices), + narrow(&self.triangle_face_ids), + wide(&self.outline_positions), + narrow(&self.outline_edge_ids), + ] + .concat() + .into_iter() + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) + } + } + + fn pinned_bodies() -> Vec<(&'static str, BrepEnvelope)> { + let accuracy = Accuracy { + geometric: 6.3e-8, + intersection: 1.575e-8, + tessellation: 0.01, + exchange: 1e-5, + }; + let vertical = Frame3 { + origin: [0.0, 0.0, 0.0], + x: [1.0, 0.0, 0.0], + y: [0.0, 0.0, -1.0], + z: [0.0, 1.0, 0.0], + }; + let cuboid = + primitives::cuboid("cuboid".into(), Frame3::IDENTITY, [4.0, 0.2, 3.0], accuracy) + .unwrap(); + let extrusion = primitives::linear_extrusion( + "extrusion".into(), + vertical, + vec![[0.0, 0.0], [5.0, 0.0], [5.0, 4.0], [2.5, 5.5], [0.0, 4.0]], + vec![vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.5], [1.0, 2.5]]], + 3.0, + accuracy, + ) + .unwrap(); + let sector = primitives::annular_sector_extrusion( + "sector".into(), + vertical, + 3.0, + 0.3, + 3.0, + 0.8, + -1.6, + accuracy, + ) + .unwrap(); + let round = primitives::cylinder( + "round".into(), + Frame3 { + origin: [2.7, 1.5, 0.0], + x: [0.0, 0.0, -1.0], + y: [0.0, 1.0, 0.0], + z: [1.0, 0.0, 0.0], + }, + 0.5, + 0.6, + accuracy, + ) + .unwrap(); + let sector_with_opening = + boolean_brep(§or, &round, BooleanOp::Subtraction, "sector-cut".into()) + .unwrap() + .brep; + let window = primitives::cuboid( + "window".into(), + Frame3 { + origin: [1.0, -0.4, 0.5], + ..Frame3::IDENTITY + }, + [1.0, 1.0, 2.0], + accuracy, + ) + .unwrap(); + let subtraction = boolean_brep(&cuboid, &window, BooleanOp::Subtraction, "cut".into()) + .unwrap() + .brep; + let placed = super::super::placement::placed( + &extrusion, + Frame3 { + origin: [12.5, -3.0, 40.0], + x: [0.0, 1.0, 0.0], + y: [-1.0, 0.0, 0.0], + z: [0.0, 0.0, 1.0], + }, + 1.0, + ) + .unwrap(); + let mut renamed = cuboid.clone(); + renamed.id = "renamed".into(); + renamed.revision = 7; + vec![ + ("cuboid", cuboid), + ("extrusion with a hole", extrusion), + ("annular sector with a round opening", sector_with_opening), + ("subtraction", subtraction), + ("placed extrusion", placed), + ("cuboid with another id and revision", renamed), + ] + } + + const PINNED_ON_AARCH64_APPLE_DARWIN: [[u64; 2]; 6] = [ + [0xcf7d_04ab_9499_4d99, 0xcf7d_04ab_9499_4d99], + [0x65d3_d4da_109d_6e6c, 0x65d3_d4da_109d_6e6c], + [0x23c3_0c17_fefc_56d8, 0x8a4e_444f_f03e_90b7], + [0x8a1a_848b_210e_60f4, 0x8a1a_848b_210e_60f4], + [0x7dd4_efe1_e278_14f8, 0x7dd4_efe1_e278_14f8], + [0xcf7d_04ab_9499_4d99, 0xcf7d_04ab_9499_4d99], + ]; + + #[test] + fn text_and_object_tessellation_give_the_pinned_triangles() { + let mut hashes = Vec::new(); + for (name, body) in pinned_bodies() { + let text = body.to_json().unwrap(); + let parsed = BrepEnvelope::from_json(&text).unwrap(); + let mut pair = [0; 2]; + for (slot, deflection) in pair.iter_mut().zip([0.05, 0.01]) { + let case = format!("{name} at {deflection}"); + let fresh = |brep: &BrepEnvelope| { + MeshBits::of_mesh( + &super::super::tessellation::tessellate( + brep, + deflection, + DEFAULT_MAX_TRIANGLES, + ) + .unwrap(), + ) + }; + let direct = fresh(&body); + assert_eq!(direct, fresh(&parsed), "{case}"); + let Ok(by_text) = tessellate_brep(&text, deflection, None) else { + panic!("{case}: the text entry failed"); + }; + assert_eq!(direct, MeshBits::of_result(&by_text), "{case}"); + assert_eq!(by_text.revision(), body.revision, "{case}"); + let by_object = + tessellate_cached(&body, deflection, DEFAULT_MAX_TRIANGLES).unwrap(); + assert_eq!(direct, MeshBits::of_result(&by_object), "{case}"); + assert_eq!(by_object.revision(), body.revision, "{case}"); + *slot = direct.fnv1a(); + } + hashes.push(pair); + } + assert_eq!(hashes[0], hashes[5]); + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + assert_eq!(hashes, PINNED_ON_AARCH64_APPLE_DARWIN, "{hashes:#018x?}"); + } + } } From 304bb4c7e22ccce9cd629015cae8ca761c544a38 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 15:58:23 +0200 Subject: [PATCH 04/29] perf(tessellation): let OGAnalyticBrep::tessellate take the triangle limit The wrapper tessellates a solid through the JSON text of its own BRep only because the text entry is the one that reads max_triangles. The object entry now takes the same optional options text and reads the limit with the same function, so the wrapper can call it. A missing option keeps DEFAULT_MAX_TRIANGLES, as before, so a caller of today does not change. The object entry parses no text, so the 64 MiB limit of the text parser does not apply to it; one test states that with a body whose id alone is 64 MiB long. --- main/opengeometry/src/analytic/wasm.rs | 58 +++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/main/opengeometry/src/analytic/wasm.rs b/main/opengeometry/src/analytic/wasm.rs index 0233bff..f918dcd 100644 --- a/main/opengeometry/src/analytic/wasm.rs +++ b/main/opengeometry/src/analytic/wasm.rs @@ -763,8 +763,13 @@ impl OGAnalyticBrep { .map_err(|e| js_error(GeometryError::InvalidGeometry(e.to_string()))) } - pub fn tessellate(&mut self, deflection: f64) -> Result { - tessellate_cached(&self.brep, deflection, DEFAULT_MAX_TRIANGLES).map_err(js_error) + pub fn tessellate( + &mut self, + deflection: f64, + options_json: Option, + ) -> Result { + let max_triangles = tessellation_limit(options_json).map_err(js_error)?; + tessellate_cached(&self.brep, deflection, max_triangles).map_err(js_error) } pub fn get_face_count(&self) -> u32 { @@ -1179,4 +1184,53 @@ mod tests { assert_eq!(hashes, PINNED_ON_AARCH64_APPLE_DARWIN, "{hashes:#018x?}"); } } + + fn wall_box() -> BrepEnvelope { + let accuracy = Accuracy { + geometric: 1e-9, + intersection: 1e-10, + tessellation: 0.001, + exchange: 1e-5, + }; + primitives::cuboid("wall".into(), Frame3::IDENTITY, [4.0, 0.2, 3.0], accuracy).unwrap() + } + + #[test] + fn object_entry_reads_the_triangle_limit_from_its_options() { + let mut body = OGAnalyticBrep::from_brep(wall_box()); + let Ok(unlimited) = body.tessellate(0.01, None) else { + panic!("the object entry failed without options"); + }; + assert_eq!(unlimited.indices().len(), 36); + let Ok(limited) = body.tessellate(0.01, Some(r#"{"max_triangles":12}"#.into())) else { + panic!("the object entry failed at the exact triangle count"); + }; + assert_eq!(limited.indices(), unlimited.indices()); + assert_eq!( + tessellation_limit(Some(r#"{"max_triangles":11}"#.into())).unwrap(), + 11 + ); + assert!(matches!( + tessellate_cached(&wall_box(), 0.01, 11), + Err(GeometryError::LimitExceeded(_)) + )); + } + + #[test] + fn object_entry_tessellates_a_body_whose_text_is_above_the_parse_limit() { + let Ok(short) = OGAnalyticBrep::from_brep(wall_box()).tessellate(0.01, None) else { + panic!("the object entry failed"); + }; + let mut body = wall_box(); + body.id = "x".repeat(64 * 1024 * 1024); + assert!(matches!( + BrepEnvelope::from_json(&body.to_json().unwrap()), + Err(GeometryError::LimitExceeded(_)) + )); + let Ok(long) = OGAnalyticBrep::from_brep(body).tessellate(0.01, None) else { + panic!("the object entry failed for a body whose text is above 64 MiB"); + }; + assert_eq!(long.positions(), short.positions()); + assert_eq!(long.indices(), short.indices()); + } } From 0b6699c22614232f57d30788efd1f41d52b4e267 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 16:00:13 +0200 Subject: [PATCH 05/29] perf(three): tessellate a solid through its kernel object AnalyticSolid.tessellate and exportStl asked the kernel for the JSON text of the body and gave that text back to tessellate_brep, so each call wrote the whole BRep as text, parsed it and validated it two more times. Both now call tessellate on the kernel object they already hold. The arrays are the same: the guard test pins that the text entry and the object entry give the same bits. The signatures of tessellate, retessellate and exportStl do not change. One case does: a body whose BRep text is above 64 MiB was rejected by the text parser and now tessellates, because no text is parsed. --- main/opengeometry-three/src/shapes/analytic-solid.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/main/opengeometry-three/src/shapes/analytic-solid.ts b/main/opengeometry-three/src/shapes/analytic-solid.ts index def85c1..299a8b9 100644 --- a/main/opengeometry-three/src/shapes/analytic-solid.ts +++ b/main/opengeometry-three/src/shapes/analytic-solid.ts @@ -1,5 +1,5 @@ import * as THREE from "three"; -import { OGAnalyticBrep, tessellate_brep } from "../../../opengeometry/pkg/opengeometry"; +import { OGAnalyticBrep } from "../../../opengeometry/pkg/opengeometry"; import { getUUID } from "../utils/randomizer"; import { GEOMETRY_DEFLECTION_SHARE, type AnalyticTessellationData } from "../rendering/analytic-tessellation"; import { matrixToWorldTransform } from "../world/world-graph"; @@ -365,10 +365,7 @@ export class AnalyticSolid extends THREE.Group { throw new Error("STL deflection must be finite and positive"); } const placed = this.worldKernel(); - const result = tessellate_brep( - placed.get_brep_serialized(), - deflection * GEOMETRY_DEFLECTION_SHARE, - ); + const result = placed.tessellate(deflection * GEOMETRY_DEFLECTION_SHARE); try { const positions = result.positions(); const indices = result.indices(); @@ -502,8 +499,7 @@ export class AnalyticSolid extends THREE.Group { && (!Number.isSafeInteger(options.maxTriangles) || options.maxTriangles <= 0 || options.maxTriangles > 2_000_000)) { throw new Error("maxTriangles must be an integer between 1 and 2000000"); } - const result = tessellate_brep( - this.kernel.get_brep_serialized(), + const result = this.kernel.tessellate( deflection, options.maxTriangles === undefined ? undefined : JSON.stringify({ max_triangles: options.maxTriangles }), ); From bfee3ce103e3cef0383a2b8108d970b170ca6079 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 16:01:04 +0200 Subject: [PATCH 06/29] test: state what the tessellation cache is keyed by and how it evicts These tests come before the cache change. Eleven fail on this commit, each at its cache-hit assertion, because they state the new rule: - nine "cache_hits_when_only_..." tests: a change of the body id, the revision, a face key or one field of the face provenance gives the same arrays from a fresh tessellation and must give a cache hit; - the cache keeps every shape of one body id under the byte bound (the old rule kept three for one id); - two equal shapes share one mesh, and each result carries the revision of the body that asked. Three pass already and must keep passing: the byte total is exact at the bound, the least recently used mesh goes first and a hit refreshes it, and a mesh above the bound is returned and not kept. --- .../opengeometry/src/analytic/tessellation.rs | 116 ++++++++++++++++++ main/opengeometry/src/analytic/wasm.rs | 13 ++ 2 files changed, 129 insertions(+) diff --git a/main/opengeometry/src/analytic/tessellation.rs b/main/opengeometry/src/analytic/tessellation.rs index 59264ef..0150a4e 100644 --- a/main/opengeometry/src/analytic/tessellation.rs +++ b/main/opengeometry/src/analytic/tessellation.rs @@ -2142,4 +2142,120 @@ mod tests { ); } } + fn wall(length: f64) -> BrepEnvelope { + primitives::cuboid( + "wall".into(), + Frame3::IDENTITY, + [length, 0.2, 3.0], + accuracy(), + ) + .unwrap() + } + fn assert_outside_the_key(change: impl Fn(&mut BrepEnvelope)) { + let original = wall(4.0); + let mut changed = original.clone(); + change(&mut changed); + assert_ne!(original.to_json().unwrap(), changed.to_json().unwrap()); + let fresh = tessellate(&original, 0.05, 1_000_000).unwrap(); + let fresh_changed = tessellate(&changed, 0.05, 1_000_000).unwrap(); + assert!(array_bits(&fresh) == array_bits(&fresh_changed)); + let mut cache = TessellationCache::new(16_000_000); + let first = cache.tessellate(&original, 0.05, 1_000_000).unwrap(); + let second = cache.tessellate(&changed, 0.05, 1_000_000).unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(cache.len(), 1); + } + #[test] + fn cache_hits_when_only_the_body_id_differs() { + assert_outside_the_key(|body| body.id = "rebuilt".into()); + } + #[test] + fn cache_hits_when_only_the_revision_differs() { + assert_outside_the_key(|body| body.revision = 7); + } + #[test] + fn cache_hits_when_only_a_face_key_differs() { + assert_outside_the_key(|body| body.topology.faces[0].key = "rebuilt:face".into()); + } + #[test] + fn cache_hits_when_only_a_provenance_entity_differs() { + assert_outside_the_key(|body| { + body.topology.faces[0].provenance.sources[0].entity = "rebuilt".into() + }); + } + #[test] + fn cache_hits_when_only_a_provenance_body_differs() { + assert_outside_the_key(|body| { + body.topology.faces[0].provenance.sources[0].body = "rebuilt".into() + }); + } + #[test] + fn cache_hits_when_only_a_provenance_key_differs() { + assert_outside_the_key(|body| { + body.topology.faces[0].provenance.sources[0].key = "rebuilt:face".into() + }); + } + #[test] + fn cache_hits_when_only_a_provenance_face_differs() { + assert_outside_the_key(|body| body.topology.faces[0].provenance.sources[0].face = 99); + } + #[test] + fn cache_hits_when_only_a_provenance_role_differs() { + assert_outside_the_key(|body| { + body.topology.faces[0].provenance.role = crate::analytic::topology::FaceRole::Split + }); + } + #[test] + fn cache_hits_when_only_a_provenance_reversed_flag_differs() { + assert_outside_the_key(|body| { + let provenance = &mut body.topology.faces[0].provenance; + provenance.reversed = !provenance.reversed; + }); + } + #[test] + fn cache_keeps_every_shape_of_one_body_id_under_the_byte_bound() { + let mut cache = TessellationCache::new(16_000_000); + for length in [1.0, 2.0, 3.0, 4.0, 5.0] { + cache.tessellate(&wall(length), 0.05, 1_000_000).unwrap(); + } + assert_eq!(cache.len(), 5); + } + #[test] + fn cache_counts_the_bytes_of_its_meshes_exactly() { + let size = tessellate(&wall(1.0), 0.05, 1_000_000).unwrap().bytes(); + for (max_bytes, kept) in [(3 * size, 3), (3 * size - 1, 2)] { + let mut cache = TessellationCache::new(max_bytes); + for length in [1.0, 2.0, 3.0] { + let mesh = cache.tessellate(&wall(length), 0.05, 1_000_000).unwrap(); + assert_eq!(mesh.bytes(), size); + } + assert_eq!(cache.len(), kept); + } + } + #[test] + fn cache_evicts_the_least_recently_used_mesh_and_a_hit_refreshes_it() { + let size = tessellate(&wall(1.0), 0.05, 1_000_000).unwrap().bytes(); + let mut cache = TessellationCache::new(3 * size); + let [first, second, third] = + [1.0, 2.0, 3.0].map(|length| cache.tessellate(&wall(length), 0.05, 1_000_000).unwrap()); + let hit = cache.tessellate(&wall(1.0), 0.05, 1_000_000).unwrap(); + assert!(Arc::ptr_eq(&first, &hit)); + cache.tessellate(&wall(4.0), 0.05, 1_000_000).unwrap(); + assert_eq!(cache.len(), 3); + let kept = cache.tessellate(&wall(3.0), 0.05, 1_000_000).unwrap(); + assert!(Arc::ptr_eq(&third, &kept)); + let refreshed = cache.tessellate(&wall(1.0), 0.05, 1_000_000).unwrap(); + assert!(Arc::ptr_eq(&first, &refreshed)); + let evicted = cache.tessellate(&wall(2.0), 0.05, 1_000_000).unwrap(); + assert!(!Arc::ptr_eq(&second, &evicted)); + assert_eq!(cache.len(), 3); + } + #[test] + fn cache_returns_but_does_not_keep_a_mesh_above_the_byte_bound() { + let size = tessellate(&wall(1.0), 0.05, 1_000_000).unwrap().bytes(); + let mut cache = TessellationCache::new(size - 1); + let mesh = cache.tessellate(&wall(1.0), 0.05, 1_000_000).unwrap(); + assert_eq!(mesh.bytes(), size); + assert!(cache.is_empty()); + } } diff --git a/main/opengeometry/src/analytic/wasm.rs b/main/opengeometry/src/analytic/wasm.rs index f918dcd..d6c3420 100644 --- a/main/opengeometry/src/analytic/wasm.rs +++ b/main/opengeometry/src/analytic/wasm.rs @@ -1233,4 +1233,17 @@ mod tests { assert_eq!(long.positions(), short.positions()); assert_eq!(long.indices(), short.indices()); } + + #[test] + fn equal_shapes_share_one_mesh_and_each_result_carries_its_own_revision() { + let first = wall_box(); + let mut second = first.clone(); + second.id = "rebuilt".into(); + second.revision = 9; + let first_result = tessellate_cached(&first, 0.02, DEFAULT_MAX_TRIANGLES).unwrap(); + let second_result = tessellate_cached(&second, 0.02, DEFAULT_MAX_TRIANGLES).unwrap(); + assert!(Arc::ptr_eq(&first_result.mesh, &second_result.mesh)); + assert_eq!(first_result.revision(), 0); + assert_eq!(second_result.revision(), 9); + } } From 252bda6546698f7aacda45a6d1a026999dc898b9 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 16:02:31 +0200 Subject: [PATCH 07/29] perf(tessellation): key the cache by the shape and find an entry without a scan The cache key was a hash of the whole BRep text, which holds the id of the body, its revision, the face keys and the face provenance. A body that is built again gets a new id, so it never hit; the cache filled to its 64 MiB bound and each call scanned all entries up to three times. The key is now a hash of what two builds of one shape share: the schema version, the geometry, the topology (of a face: id, surface, sense, trim and shell), the solids, the accuracy, the quality, the deflection and the triangle limit. It leaves out the body id, the revision, the face key and the face provenance. cache_key takes the envelope, the topology and each face apart field by field, so a new field does not compile until someone decides whether it belongs to the key. A hit now can come from another body, so a clash of the hash would give the mesh of another shape. The key is therefore 128 bits: two hashers of the standard library with different fixed prefixes. The entries are a map by key with a recency order and a running byte total: one lookup, one recency update on a hit, evictions from the oldest end until the total is under the bound. The 64 MiB bound stays. A cached mesh can serve bodies of different revisions, so the result that goes to JavaScript carries the revision of the body that asked. cache_tracks_geometry_even_when_revision_is_not_incremented changed in two places. It changed a face key and expected a miss; a face key is a label and no longer in the key, so it now changes the radius, which is what its name says. It counted three entries after six inserts for one body id; the rule "three entries for one body id" is gone because the body id is not in the key, so all six stay under the byte bound. --- .../opengeometry/src/analytic/tessellation.rs | 164 +++++++++++++----- main/opengeometry/src/analytic/wasm.rs | 8 +- 2 files changed, 123 insertions(+), 49 deletions(-) diff --git a/main/opengeometry/src/analytic/tessellation.rs b/main/opengeometry/src/analytic/tessellation.rs index 0150a4e..ae26be5 100644 --- a/main/opengeometry/src/analytic/tessellation.rs +++ b/main/opengeometry/src/analytic/tessellation.rs @@ -1,11 +1,11 @@ use super::{ geometry::{cross, dot, norm, scale, sub, Surface}, - topology::{BrepEnvelope, EdgeGeometry, Face, Orientation}, + topology::{BrepEnvelope, EdgeGeometry, Face, Orientation, Topology}, Curve, CurveGeometry, GeometryError, Point3, SurfaceGeometry, }; use crate::math::interval::Interval; use std::{ - collections::{BTreeSet, HashMap, HashSet, VecDeque}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, hash::{DefaultHasher, Hasher}, io::Write, sync::Arc, @@ -1610,31 +1610,107 @@ pub fn tessellate( Ok(mesh) } -struct HashWriter(DefaultHasher); +type CacheKey = [u64; 2]; + +struct HashWriter([DefaultHasher; 2]); +impl HashWriter { + fn new() -> Self { + let mut hashers = [DefaultHasher::new(), DefaultHasher::new()]; + hashers[0].write_u8(0); + hashers[1].write_u8(1); + Self(hashers) + } +} impl Write for HashWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.write(buf); + for hasher in &mut self.0 { + hasher.write(buf); + } Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } -struct CacheEntry { - body: u64, - signature: u64, - deflection: u64, +fn cache_key( + brep: &BrepEnvelope, + deflection: f64, max_triangles: usize, +) -> Result { + let BrepEnvelope { + schema_version, + id: _, + revision: _, + geometry, + topology, + solids, + accuracy, + quality, + } = brep; + let Topology { + vertices, + edges, + halfedges, + loops, + faces, + wires, + shells, + } = topology; + let faces = faces + .iter() + .map( + |Face { + id, + key: _, + surface, + sense, + trim, + shell_ref, + provenance: _, + }| (id, surface, sense, trim, shell_ref), + ) + .collect::>(); + let mut writer = HashWriter::new(); + serde_json::to_writer( + &mut writer, + &( + schema_version, + geometry, + vertices, + edges, + halfedges, + loops, + faces, + wires, + shells, + solids, + accuracy, + quality, + deflection.to_bits(), + max_triangles, + ), + ) + .map_err(|e| GeometryError::InvalidGeometry(e.to_string()))?; + Ok(writer.0.map(|hasher| hasher.finish())) +} +struct CacheEntry { mesh: Arc, + stamp: u64, } pub struct TessellationCache { - entries: VecDeque, + entries: HashMap, + recency: BTreeMap, + stamp: u64, + bytes: usize, max_bytes: usize, } impl TessellationCache { pub fn new(max_bytes: usize) -> Self { Self { - entries: VecDeque::new(), + entries: HashMap::new(), + recency: BTreeMap::new(), + stamp: 0, + bytes: 0, max_bytes, } } @@ -1645,49 +1721,35 @@ impl TessellationCache { max_triangles: usize, ) -> Result, GeometryError> { brep.validate()?; - let mut writer = HashWriter(DefaultHasher::new()); - serde_json::to_writer(&mut writer, brep) - .map_err(|e| GeometryError::InvalidGeometry(e.to_string()))?; - let signature = writer.0.finish(); - if let Some(i) = self.entries.iter().position(|e| { - e.signature == signature - && e.deflection == deflection.to_bits() - && e.max_triangles == max_triangles - }) { - let entry = self - .entries - .remove(i) - .ok_or_else(|| GeometryError::InvalidGeometry("cache entry missing".into()))?; - let mesh = Arc::clone(&entry.mesh); - self.entries.push_back(entry); - return Ok(mesh); + let key = cache_key(brep, deflection, max_triangles)?; + self.stamp += 1; + if let Some(entry) = self.entries.get_mut(&key) { + self.recency.remove(&entry.stamp); + entry.stamp = self.stamp; + self.recency.insert(self.stamp, key); + return Ok(Arc::clone(&entry.mesh)); } let mesh = Arc::new(tessellate(brep, deflection, max_triangles)?); if mesh.bytes() > self.max_bytes { return Ok(mesh); } - let mut hasher = DefaultHasher::new(); - hasher.write(brep.id.as_bytes()); - let body = hasher.finish(); - while self.entries.iter().filter(|e| e.body == body).count() >= 3 { - if let Some(i) = self.entries.iter().position(|e| e.body == body) { - self.entries.remove(i); - } else { + self.bytes += mesh.bytes(); + self.entries.insert( + key, + CacheEntry { + mesh: Arc::clone(&mesh), + stamp: self.stamp, + }, + ); + self.recency.insert(self.stamp, key); + while self.bytes > self.max_bytes { + let Some((_, oldest)) = self.recency.pop_first() else { break; + }; + if let Some(evicted) = self.entries.remove(&oldest) { + self.bytes -= evicted.mesh.bytes(); } } - while self.entries.iter().map(|e| e.mesh.bytes()).sum::() + mesh.bytes() - > self.max_bytes - { - self.entries.pop_front(); - } - self.entries.push_back(CacheEntry { - body, - signature, - deflection: deflection.to_bits(), - max_triangles, - mesh: Arc::clone(&mesh), - }); Ok(mesh) } pub fn len(&self) -> usize { @@ -1993,13 +2055,13 @@ mod tests { let a = cache.tessellate(&b, 0.1, 1_000_000).unwrap(); let again = cache.tessellate(&b, 0.1, 1_000_000).unwrap(); assert!(Arc::ptr_eq(&a, &again)); - b.topology.faces[0].key = "changed".into(); + b = primitives::cylinder("c".into(), Frame3::IDENTITY, 1.5, 2.0, accuracy()).unwrap(); let changed = cache.tessellate(&b, 0.1, 1_000_000).unwrap(); assert!(!Arc::ptr_eq(&a, &changed)); for d in [0.08, 0.06, 0.04, 0.02] { cache.tessellate(&b, d, 1_000_000).unwrap(); } - assert_eq!(cache.len(), 3); + assert_eq!(cache.len(), 6); assert!(tessellate(&b, 0.001, 1).is_err()); } fn trimmed_curved_bodies() -> [BrepEnvelope; 2] { @@ -2258,4 +2320,12 @@ mod tests { assert_eq!(mesh.bytes(), size); assert!(cache.is_empty()); } + #[test] + fn cache_key_is_two_different_hashes_of_one_shape() { + let key = cache_key(&wall(4.0), 0.05, 1_000_000).unwrap(); + assert_ne!(key[0], key[1]); + assert_eq!(key, cache_key(&wall(4.0), 0.05, 1_000_000).unwrap()); + assert_ne!(key, cache_key(&wall(4.0), 0.05, 999_999).unwrap()); + assert_ne!(key, cache_key(&wall(4.0), 0.04, 1_000_000).unwrap()); + } } diff --git a/main/opengeometry/src/analytic/wasm.rs b/main/opengeometry/src/analytic/wasm.rs index d6c3420..7fff34f 100644 --- a/main/opengeometry/src/analytic/wasm.rs +++ b/main/opengeometry/src/analytic/wasm.rs @@ -77,7 +77,10 @@ fn tessellate_cached( })? .tessellate(brep, deflection, max_triangles) })?; - Ok(OGAnalyticTessellation { mesh }) + Ok(OGAnalyticTessellation { + mesh, + revision: brep.revision, + }) } #[derive(Deserialize)] @@ -803,6 +806,7 @@ impl OGAnalyticBrep { #[wasm_bindgen] pub struct OGAnalyticTessellation { mesh: Arc, + revision: u64, } #[wasm_bindgen] impl OGAnalyticTessellation { @@ -825,7 +829,7 @@ impl OGAnalyticTessellation { self.mesh.outline_edge_ids.clone() } pub fn revision(&self) -> u64 { - self.mesh.revision + self.revision } pub fn achieved_deflection(&self) -> f64 { self.mesh.achieved_deflection From 7b28644c6d00944616f338b7b7a1df01df40b0a7 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 16:19:32 +0200 Subject: [PATCH 08/29] test: a field that the tessellation reads misses the cache when it changes The "cache_hits_when_only_..." tests guard one direction: a field that is left out of the key must not change the result. Nothing guarded the other one: a field that the tessellation reads could be dropped from the key (for example `accuracy: _`) and no test failed, although the cache would then give the mesh of another shape. Each new test changes one such field of a valid body and requires that the cache gives what a fresh tessellation gives: another mesh and a second entry, or the same error. Six also require that the fresh result changes: the geometric accuracy (the fresh call is an error), a vertex position, a face sense, a face trim, a curve and a surface. For the intersection accuracy no cheap pair of valid bodies gives another result, so that test requires the miss alone. Each case was shown to fail with its field taken out of the key, and only that case; the key is unchanged by this commit. --- .../opengeometry/src/analytic/tessellation.rs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/main/opengeometry/src/analytic/tessellation.rs b/main/opengeometry/src/analytic/tessellation.rs index ae26be5..109fee0 100644 --- a/main/opengeometry/src/analytic/tessellation.rs +++ b/main/opengeometry/src/analytic/tessellation.rs @@ -2274,6 +2274,80 @@ mod tests { provenance.reversed = !provenance.reversed; }); } + fn cache_miss_changes_the_result( + original: &BrepEnvelope, + change: impl Fn(&mut BrepEnvelope), + ) -> bool { + let mut changed = original.clone(); + change(&mut changed); + changed.validate().unwrap(); + let fresh = tessellate(original, 0.05, 1_000_000).unwrap(); + let fresh_changed = tessellate(&changed, 0.05, 1_000_000); + let mut cache = TessellationCache::new(16_000_000); + let first = cache.tessellate(original, 0.05, 1_000_000).unwrap(); + match (fresh_changed, cache.tessellate(&changed, 0.05, 1_000_000)) { + (Ok(fresh_changed), Ok(second)) => { + assert!(!Arc::ptr_eq(&first, &second)); + assert!(array_bits(&fresh_changed) == array_bits(&second)); + assert_eq!(cache.len(), 2); + array_bits(&fresh) != array_bits(&fresh_changed) + } + (Err(_), Err(_)) => { + assert_eq!(cache.len(), 1); + true + } + _ => panic!("the cache and a fresh tessellation disagree"), + } + } + fn assert_inside_the_key(original: &BrepEnvelope, change: impl Fn(&mut BrepEnvelope)) { + assert!(cache_miss_changes_the_result(original, change)); + } + fn unit_cylinder() -> BrepEnvelope { + primitives::cylinder("c".into(), Frame3::IDENTITY, 1.0, 2.0, accuracy()).unwrap() + } + #[test] + fn cache_misses_when_the_geometric_accuracy_differs() { + assert_inside_the_key(&wall(4.0), |body| body.accuracy.geometric = 0.03); + } + #[test] + fn cache_misses_when_the_intersection_accuracy_differs() { + cache_miss_changes_the_result(&wall(4.0), |body| body.accuracy.intersection = 5e-11); + } + #[test] + fn cache_misses_when_a_vertex_position_differs() { + assert_inside_the_key(&wall(4.0), |body| { + body.topology.vertices[0].position[0] += 2.5e-10 + }); + } + #[test] + fn cache_misses_when_a_face_sense_differs() { + assert_inside_the_key(&wall(4.0), |body| { + body.topology.faces[0].sense = Orientation::Reverse + }); + } + #[test] + fn cache_misses_when_a_face_trim_differs() { + assert_inside_the_key(&unit_cylinder(), |body| { + body.topology.faces[0].trim.uv_bounds[1] = Interval { lo: -0.5, hi: 2.5 } + }); + } + #[test] + fn cache_misses_when_a_curve_differs() { + assert_inside_the_key(&unit_cylinder(), |body| { + if let CurveGeometry::Circle { radius, .. } = &mut body.geometry.curves[0] { + *radius += 2.5e-10; + } + }); + } + #[test] + fn cache_misses_when_a_surface_differs() { + let sphere = primitives::sphere("s".into(), Frame3::IDENTITY, 1.0, accuracy()).unwrap(); + assert_inside_the_key(&sphere, |body| { + if let SurfaceGeometry::Sphere { radius, .. } = &mut body.geometry.surfaces[0] { + *radius += 2.5e-10; + } + }); + } #[test] fn cache_keeps_every_shape_of_one_body_id_under_the_byte_bound() { let mut cache = TessellationCache::new(16_000_000); From a83db7597d6db251c89f18e88565ee353624d867 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 16:19:32 +0200 Subject: [PATCH 09/29] test: the guard and the limit test use the entry that the wrapper calls The guard called the private tessellate_cached as its object entry, and on the cache entry that the text call had just made. It now calls OGAnalyticBrep::tessellate, the method of the wrapper, on a thread of its own, so the object entry computes its mesh in an empty cache. The limit test asked for exactly the 12 triangles of the body, so its result proved nothing. It now also requires a second cache entry, which exists only when the limit is read from the options. --- main/opengeometry/src/analytic/wasm.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/main/opengeometry/src/analytic/wasm.rs b/main/opengeometry/src/analytic/wasm.rs index 7fff34f..994702d 100644 --- a/main/opengeometry/src/analytic/wasm.rs +++ b/main/opengeometry/src/analytic/wasm.rs @@ -1175,10 +1175,20 @@ mod tests { }; assert_eq!(direct, MeshBits::of_result(&by_text), "{case}"); assert_eq!(by_text.revision(), body.revision, "{case}"); - let by_object = - tessellate_cached(&body, deflection, DEFAULT_MAX_TRIANGLES).unwrap(); - assert_eq!(direct, MeshBits::of_result(&by_object), "{case}"); - assert_eq!(by_object.revision(), body.revision, "{case}"); + let (by_object, revision) = std::thread::scope(|scope| { + scope + .spawn(|| { + let mut object = OGAnalyticBrep::from_brep(body.clone()); + let Ok(result) = object.tessellate(deflection, None) else { + panic!("{case}: the object entry failed"); + }; + (MeshBits::of_result(&result), result.revision()) + }) + .join() + }) + .unwrap(); + assert_eq!(direct, by_object, "{case}"); + assert_eq!(revision, body.revision, "{case}"); *slot = direct.fnv1a(); } hashes.push(pair); @@ -1210,6 +1220,7 @@ mod tests { panic!("the object entry failed at the exact triangle count"); }; assert_eq!(limited.indices(), unlimited.indices()); + assert!(!Arc::ptr_eq(&limited.mesh, &unlimited.mesh)); assert_eq!( tessellation_limit(Some(r#"{"max_triangles":11}"#.into())).unwrap(), 11 From 60b178a751a6c756409951f39814d10989363eb7 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 16:19:50 +0200 Subject: [PATCH 10/29] docs(tessellation): state the rule of the cache key and of the cached revision Two invariants are not visible in the code. The key must hold every field that the tessellation reads, and a field may be left out only if it cannot change the result; a person who adds a field has to decide that. And the cache is public while a hit can return a mesh that was made for another body, so the revision inside the mesh is not the caller's: wasm.rs, the only caller, stamps its own. --- main/opengeometry/src/analytic/tessellation.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main/opengeometry/src/analytic/tessellation.rs b/main/opengeometry/src/analytic/tessellation.rs index 109fee0..ea60454 100644 --- a/main/opengeometry/src/analytic/tessellation.rs +++ b/main/opengeometry/src/analytic/tessellation.rs @@ -1632,6 +1632,7 @@ impl Write for HashWriter { Ok(()) } } +// Every field that `tessellate` reads is in the stream; a field left out must not change the result. fn cache_key( brep: &BrepEnvelope, deflection: f64, @@ -1714,6 +1715,7 @@ impl TessellationCache { max_bytes, } } + // A hit can be the mesh of another body, so its `revision` is not the caller's; the caller stamps its own. pub fn tessellate( &mut self, brep: &BrepEnvelope, From 69586ad2a79db43d2c5e04788ece31b44f36471f Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 16:55:51 +0200 Subject: [PATCH 11/29] test: the default accuracy of a body does not depend on its position The first test of the wrapper. It runs on the built package with node:test (npm run test:wrapper, after npm run build) and has no dependency. Four of its six cases fail on this commit, because the default accuracy takes the largest absolute profile coordinate as the size of the body: - a 5 m wall has geometric 5e-8 at the origin and 1.05e-6 at (100, 100); - a slab centred on the origin gets the accuracy of half its size; - a 0.05 m prism at (50000, 50000) cannot be made at all: its accuracy is 5e-4 and the first tessellation rejects the default deflection; - a 1 m cuboid at (3e6, 0, 3e6) with a matrix fails the placement check of the kernel, which takes the norm of the far corner where the wrapper takes the largest component of the frame origin. Two pass already and must keep passing: an explicit accuracy goes through unchanged, and a door cut at (5000, 5000) equals the cut at the origin after the translation. --- .../tests/accuracy-rule.test.mjs | 123 ++++++++++++++++++ package.json | 1 + 2 files changed, 124 insertions(+) create mode 100644 main/opengeometry-three/tests/accuracy-rule.test.mjs diff --git a/main/opengeometry-three/tests/accuracy-rule.test.mjs b/main/opengeometry-three/tests/accuracy-rule.test.mjs new file mode 100644 index 0000000..3129405 --- /dev/null +++ b/main/opengeometry-three/tests/accuracy-rule.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { AnalyticSolid, OpenGeometry } from "../../../dist/index.js"; + +const distFolder = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist"); +const consumerAxes = { x: [1, 0, 0], y: [0, 0, -1], z: [0, 1, 0] }; +const madeBodies = []; + +before(async () => { + if (typeof globalThis.document === "undefined") { + globalThis.document = { + createElement: () => ({ style: {}, setAttribute() {}, appendChild() {} }), + }; + } + const wasmBytes = await readFile(path.join(distFolder, "opengeometry", "pkg", "opengeometry_bg.wasm")); + await OpenGeometry.create({ wasmURL: wasmBytes }); +}); + +afterEach(() => { + for (const body of madeBodies.splice(0)) body.dispose(); +}); + +function keep(body) { + madeBodies.push(body); + return body; +} + +function prism(left, bottom, width, depth, height, extra = {}) { + return keep(new AnalyticSolid({ + kind: "linearExtrusion", + frame: { origin: [0, 0, 0], ...consumerAxes }, + outer: [[left, bottom], [left + width, bottom], [left + width, bottom + depth], [left, bottom + depth]], + holes: [], + height, + ...extra, + })); +} + +function wallAt(left, bottom, extra = {}) { + return prism(left, bottom, 5, 0.2, 3, extra); +} + +function movedOffIdentity(body) { + body.position.y = 0.001; + body.updateMatrixWorld(true); + return body; +} + +function assertRelative(actual, expected, relative, label) { + assert.ok( + Math.abs(actual - expected) <= relative * Math.abs(expected), + `${label}: ${actual} is not ${expected} within ${relative} relative`, + ); +} + +test("a wall gets the same default accuracy at every distance from the origin", (context) => { + const atOrigin = wallAt(0, 0).getBrepData().accuracy; + assertRelative(atOrigin.geometric, 5 * 1e-8, 1e-9, "wall at the origin"); + for (const [left, bottom] of [[100, 100], [5000, 5000], [1234.567, -4321.0123]]) { + const accuracy = wallAt(left, bottom).getBrepData().accuracy; + context.diagnostic(`wall at (${left}, ${bottom}): geometric ${accuracy.geometric}`); + assertRelative(accuracy.geometric, atOrigin.geometric, 1e-9, `wall at (${left}, ${bottom}) against the origin`); + assertRelative(accuracy.geometric, 5 * 1e-8, 1e-9, `wall at (${left}, ${bottom})`); + assert.equal(accuracy.intersection, accuracy.geometric / 4); + } +}); + +test("a body centred on the origin gets the accuracy of its full size", (context) => { + const accuracy = prism(-2.6, -2.1, 5.2, 4.2, 0.2).getBrepData().accuracy; + context.diagnostic(`slab 5.2 x 4.2 m centred on the origin: geometric ${accuracy.geometric}`); + assertRelative(accuracy.geometric, 5.2 * 1e-8, 1e-12, "slab centred on the origin"); +}); + +test("a small far body with a matrix passes the placement check of the kernel", (context) => { + const body = movedOffIdentity(prism(50000, 50000, 0.05, 0.02, 0.05)); + const local = body.getBrepData().accuracy.geometric; + const world = JSON.parse(body.getWorldBrepSerialized()).accuracy.geometric; + context.diagnostic(`0.05 m prism at (50000, 50000): geometric ${local}`); + assert.equal(world, local); +}); + +test("a cuboid far on the diagonal with a matrix passes the placement check of the kernel", (context) => { + const body = movedOffIdentity(keep(new AnalyticSolid({ + kind: "cuboid", + width: 1, + depth: 1, + height: 1, + frame: { origin: [3e6, 0, 3e6], ...consumerAxes }, + }))); + const world = JSON.parse(body.getWorldBrepSerialized()).accuracy.geometric; + context.diagnostic(`1 m cuboid at (3e6, 0, 3e6): geometric ${world}`); + assert.equal(world, body.getBrepData().accuracy.geometric); +}); + +test("an explicit accuracy passes through unchanged", () => { + const accuracy = { geometric: 1e-7, intersection: 2.5e-8, tessellation: 0.001, exchange: 0.00001 }; + assert.deepEqual(wallAt(5000, 5000, { accuracy }).getBrepData().accuracy, accuracy); +}); + +test("a door cut of a far wall equals the cut at the origin after the translation", (context) => { + const cutAt = (left, bottom) => { + const cutter = prism(left + 1.5, bottom - 0.1, 0.9, 0.4, 2.1); + return keep(wallAt(left, bottom).subtract([cutter])).getBrepData().topology; + }; + const near = cutAt(0, 0); + const far = cutAt(5000, 5000); + const translation = [5000, 0, -5000]; + assert.equal(far.faces.length, near.faces.length); + assert.equal(far.edges.length, near.edges.length); + assert.equal(far.vertices.length, near.vertices.length); + let largest = 0; + far.vertices.forEach((vertex, index) => { + vertex.position.forEach((coordinate, axis) => { + const difference = Math.abs(coordinate - translation[axis] - near.vertices[index].position[axis]); + largest = Math.max(largest, difference); + }); + }); + context.diagnostic(`largest vertex difference ${largest} m over ${near.vertices.length} vertices`); + assert.ok(largest <= 1e-9, `largest vertex difference ${largest} m`); +}); diff --git a/package.json b/package.json index aa17bea..e10685b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "dev": "vite", "test": "cargo test --manifest-path main/opengeometry/Cargo.toml && cargo test --examples --manifest-path main/opengeometry/Cargo.toml", + "test:wrapper": "node --test main/opengeometry-three/tests/*.test.mjs", "lint": "eslint main/opengeometry-three/src/**/*.ts --fix", "lint:check": "eslint main/opengeometry-three/src/**/*.ts", "clean-dist": "node ./scripts/clean-dist.mjs", From 05de7a9810356fa6db39cf167395e9337a714d30 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 16:58:46 +0200 Subject: [PATCH 12/29] fix(three): compute the default accuracy of a body from its size and reach The default accuracy of a new body took the largest absolute profile coordinate as the size of the body. That is the distance from the origin, not the size, so a wall at 100 m got a geometric tolerance above 1e-6 and one at 40 km could not be tessellated at all. A body at 100 m is not less exact than the same body at the origin. For the kinds that are given by points (linear and arc-edged extrusions, polygon lofts, planar polyhedra) the size is now the largest side of the box of those points in the frame of the body. The other kinds keep their size expression. The coordinate floor follows what the placement check of the kernel computes: the norm, over the world axes, of the largest absolute coordinate of the corners of that box (for the other kinds a cube of the size around the frame origin). It is taken 128 times the machine epsilon, twice the factor of the kernel, so a body that a matrix moves a little still passes that check. Two results change. A body centred on its frame origin had half its extent as size and now has the full extent, so its default tolerance is up to twice the former one. A body far from the origin gets the tolerance of its size where it got the tolerance of its distance. An explicit accuracy option is unchanged. --- .../src/shapes/analytic-solid.ts | 79 ++++++++++++++----- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/main/opengeometry-three/src/shapes/analytic-solid.ts b/main/opengeometry-three/src/shapes/analytic-solid.ts index 299a8b9..0da87ab 100644 --- a/main/opengeometry-three/src/shapes/analytic-solid.ts +++ b/main/opengeometry-three/src/shapes/analytic-solid.ts @@ -627,25 +627,64 @@ export class AnalyticSolid extends THREE.Group { } } +interface LocalBox { + lo: [number, number, number]; + hi: [number, number, number]; +} + +function pointBoxOf(options: AnalyticPrimitiveOptions): LocalBox | undefined { + const points: [number, number, number][] = options.kind === "polygonLoft" + ? [...options.lower, ...options.upper] + : options.kind === "planarPolyhedron" ? [...options.vertices] : []; + const addProfilePoint = (point: [number, number], height: number): void => { + points.push([point[0], point[1], 0], [point[0], point[1], height]); + }; + if (options.kind === "linearExtrusion") { + for (const loop of [options.outer, ...options.holes]) { + for (const point of loop) addProfilePoint(point, options.height); + } + } else if (options.kind === "arcEdgedExtrusion") { + for (const ring of [options.outer, ...(options.holes ?? [])]) { + for (const edge of ring) { + const corners: [number, number][] = edge.kind === "line" + ? [edge.from, edge.to] + : [[edge.center[0] - edge.radius, edge.center[1] - edge.radius], [edge.center[0] + edge.radius, edge.center[1] + edge.radius]]; + for (const corner of corners) addProfilePoint(corner, options.height); + } + } + } + if (points.length === 0) return undefined; + const box: LocalBox = { lo: [Infinity, Infinity, Infinity], hi: [-Infinity, -Infinity, -Infinity] }; + for (const point of points) { + for (let axis = 0; axis < 3; axis += 1) { + box.lo[axis] = Math.min(box.lo[axis], point[axis]); + box.hi[axis] = Math.max(box.hi[axis], point[axis]); + } + } + return box; +} + +function worldReachOf(frame: AnalyticFrame, box: LocalBox): number { + const farthest = [0, 0, 0]; + for (const alongX of [box.lo[0], box.hi[0]]) { + for (const alongY of [box.lo[1], box.hi[1]]) { + for (const alongZ of [box.lo[2], box.hi[2]]) { + for (let axis = 0; axis < 3; axis += 1) { + const coordinate = frame.origin[axis] + alongX * frame.x[axis] + alongY * frame.y[axis] + alongZ * frame.z[axis]; + farthest[axis] = Math.max(farthest[axis], Math.abs(coordinate)); + } + } + } + } + return Math.hypot(farthest[0], farthest[1], farthest[2]); +} + function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string): OGAnalyticBrep { - const frame = options.frame ?? (options.kind === "planarPolyhedron" || options.kind === "polygonLoft" + const frame: AnalyticFrame = options.frame ?? (options.kind === "planarPolyhedron" || options.kind === "polygonLoft" ? { origin: [0, 0, 0], x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, 1] } : { origin: [0, 0, 0], x: [1, 0, 0], y: [0, 0, -1], z: [0, 1, 0] }); - const profileExtent = options.kind === "linearExtrusion" - ? [options.outer, ...options.holes].reduce((extent, loop) => ( - loop.reduce((loopExtent, point) => Math.max(loopExtent, Math.abs(point[0]), Math.abs(point[1])), extent) - ), 0) - : options.kind === "arcEdgedExtrusion" - ? [options.outer, ...(options.holes ?? [])].reduce((extent, ring) => ring.reduce((loopExtent, edge) => edge.kind === "line" - ? Math.max(loopExtent, ...edge.from.map(Math.abs), ...edge.to.map(Math.abs)) - : Math.max(loopExtent, Math.abs(edge.center[0]) + edge.radius, Math.abs(edge.center[1]) + edge.radius), extent), 0) - : 0; - const loftExtent = options.kind === "polygonLoft" - ? [...options.lower, ...options.upper].reduce((extent, point) => Math.max(extent, ...point.map(Math.abs)), 0) - : 0; - const polyhedronExtent = options.kind === "planarPolyhedron" - ? options.vertices.reduce((extent, point) => Math.max(extent, Math.abs(point[0]), Math.abs(point[1]), Math.abs(point[2])), 0) - : 0; + const pointBox = pointBoxOf(options); + const pointSize = pointBox ? Math.max(...pointBox.hi.map((high, axis) => high - pointBox.lo[axis])) : 0; const size = options.kind === "torus" ? 2 * (options.majorRadius + options.minorRadius) : options.kind === "coaxialCircleLoft" @@ -655,13 +694,13 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string): : options.kind === "chamferedCuboid" ? Math.max(options.width, options.depth, options.height) : options.kind === "linearExtrusion" || options.kind === "arcEdgedExtrusion" - ? Math.max(options.height, profileExtent) + ? pointSize : options.kind === "polygonLoft" - ? loftExtent + ? pointSize : options.kind === "boxWithArchedOpening" ? Math.max(options.width, options.depth, options.height) : options.kind === "planarPolyhedron" - ? polyhedronExtent + ? pointSize : options.kind === "frustum" ? Math.max(2 * options.lowerRadius, 2 * options.upperRadius, options.height) : options.kind === "annularSectorExtrusion" || options.kind === "annularSectorExtrusionWithOpenings" || options.kind === "annularSectorExtrusionWithArchedOpening" @@ -671,7 +710,7 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string): : options.kind === "sphere" ? 2 * options.radius : Math.max(2 * options.radius, options.height); - const coordinateFloor = Math.max(...frame.origin.map(Math.abs)) * 64 * Number.EPSILON; + const coordinateFloor = 128 * Number.EPSILON * worldReachOf(frame, pointBox ?? { lo: [-size, -size, -size], hi: [size, size, size] }); const geometric = Math.max(1e-9, size * 1e-8, coordinateFloor); const accuracy = options.accuracy ?? { geometric, intersection: geometric / 4, tessellation: 0.001, exchange: 0.00001, From 447b0e1549c4a489cc1ecd7cddbc9d3d55d2a7fc Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 17:02:32 +0200 Subject: [PATCH 13/29] test: the box of an arc edge is the box of the arc, not of its circle A wall that curves gently has a large radius and a short arc. The size rule takes the whole circle of an arc edge (centre plus and minus the radius), so a wall of radius 80 m with a sweep of 10 degrees gets the size 160.6 m and the tolerance 1.606e-6, where its own box is 10 m wide. Four new cases; the expected box comes from the end points and the axis extremes that each case names. Three fail on this commit: the gently curved wall near and at (5000, 5000), the sweeps that pass one axis extreme, and the ring written in both directions. The sweep of 270 degrees passes already, because there the box is as wide as the circle. --- .../tests/accuracy-rule.test.mjs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/main/opengeometry-three/tests/accuracy-rule.test.mjs b/main/opengeometry-three/tests/accuracy-rule.test.mjs index 3129405..9164252 100644 --- a/main/opengeometry-three/tests/accuracy-rule.test.mjs +++ b/main/opengeometry-three/tests/accuracy-rule.test.mjs @@ -121,3 +121,78 @@ test("a door cut of a far wall equals the cut at the origin after the translatio context.diagnostic(`largest vertex difference ${largest} m over ${near.vertices.length} vertices`); assert.ok(largest <= 1e-9, `largest vertex difference ${largest} m`); }); + +const degrees = Math.PI / 180; + +function curvedWall({ start, sweep, origin = [0, 0, 0], clockwise = false }) { + const [inner, outer] = [80, 80.3]; + const at = (radius, angle) => [radius * Math.cos(angle), radius * Math.sin(angle)]; + const arc = (radius, startAngle, sweepAngle) => ({ kind: "arc", center: [0, 0], radius, startAngle, sweepAngle }); + const ring = [ + arc(outer, start, sweep), + { kind: "line", from: at(outer, start + sweep), to: at(inner, start + sweep) }, + arc(inner, start + sweep, -sweep), + { kind: "line", from: at(inner, start), to: at(outer, start) }, + ]; + const reversed = [...ring].reverse().map((edge) => (edge.kind === "line" + ? { kind: "line", from: edge.to, to: edge.from } + : arc(edge.radius, edge.startAngle + edge.sweepAngle, -edge.sweepAngle))); + const body = keep(new AnalyticSolid({ + kind: "arcEdgedExtrusion", + frame: { origin, ...consumerAxes }, + outer: clockwise ? reversed : ring, + height: 3, + })); + return body.getBrepData().accuracy.geometric; +} + +function boxRuleOf({ start, sweep, extremes }) { + const [inner, outer] = [80, 80.3]; + const corners = [start, start + sweep].flatMap((angle) => [inner, outer].map( + (radius) => [radius * Math.cos(angle), radius * Math.sin(angle)], + )); + const points = [...corners, ...extremes.map(([alongX, alongY]) => [alongX * outer, alongY * outer])]; + const side = (axis) => Math.max(...points.map((point) => point[axis])) - Math.min(...points.map((point) => point[axis])); + return Math.max(3, side(0), side(1)) * 1e-8; +} + +test("a gently curved wall gets the accuracy of its own box, near and far", (context) => { + const wall = { start: 40 * degrees, sweep: 10 * degrees }; + const expected = boxRuleOf({ ...wall, extremes: [] }); + const near = curvedWall(wall); + const far = curvedWall({ ...wall, origin: [5000, 0, 5000] }); + context.diagnostic(`radius 80 m, sweep 10 degrees: geometric ${near} near, ${far} at (5000, 5000)`); + assertRelative(near, expected, 1e-9, "curved wall at the origin"); + assertRelative(far, expected, 1e-9, "curved wall at (5000, 5000)"); + assert.ok(near < 1e-6); +}); + +test("the box of an arc holds each axis extreme that its sweep passes", (context) => { + const cases = [ + { start: 80 * degrees, sweep: 20 * degrees, extremes: [[0, 1]] }, + { start: -10 * degrees, sweep: 20 * degrees, extremes: [[1, 0]] }, + { start: 170 * degrees, sweep: 20 * degrees, extremes: [[-1, 0]] }, + { start: 260 * degrees, sweep: 20 * degrees, extremes: [[0, -1]] }, + ]; + for (const wall of cases) { + const geometric = curvedWall(wall); + context.diagnostic(`sweep from ${Math.round(wall.start / degrees)} degrees: geometric ${geometric}`); + assertRelative(geometric, boxRuleOf(wall), 1e-9, `sweep from ${Math.round(wall.start / degrees)} degrees`); + } +}); + +test("an arc of more than a half turn is as wide as its circle where it passes both extremes", (context) => { + const wall = { start: -135 * degrees, sweep: 270 * degrees, extremes: [[1, 0], [0, 1], [0, -1]] }; + const geometric = curvedWall(wall); + context.diagnostic(`sweep of 270 degrees: geometric ${geometric}`); + assertRelative(geometric, 2 * 80.3 * 1e-8, 1e-9, "sweep of 270 degrees against the circle"); + assertRelative(geometric, boxRuleOf(wall), 1e-9, "sweep of 270 degrees"); +}); + +test("an arc gives the same box in both directions", () => { + for (const wall of [{ start: 40 * degrees, sweep: 10 * degrees, extremes: [] }, { start: 80 * degrees, sweep: 20 * degrees, extremes: [[0, 1]] }]) { + const expected = boxRuleOf(wall); + assertRelative(curvedWall(wall), expected, 1e-9, "counter-clockwise ring"); + assertRelative(curvedWall({ ...wall, clockwise: true }), expected, 1e-9, "clockwise ring"); + } +}); From 69a100165c8c1eb4ce4843ae656d7ee1353397c9 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 17:03:03 +0200 Subject: [PATCH 14/29] fix(three): take the exact box of an arc edge for the default accuracy The box of an arc-edged body took the whole circle of each arc. A wall that curves gently has a large radius and a short arc: with a radius of 80 m and a sweep of 10 degrees its size was 160.6 m and its tolerance 1.606e-6, where its own box is 10 m wide. Before the size rule such a wall, with its centre near the origin, had 8e-7, so the rule made a normal curved wall less exact than it was. An arc now gives its two end points and each axis extreme of its circle that the sweep passes. The convention is the one of the kernel (ProfileEdge::endpoints in analytic/primitives.rs): a point at an angle is centre + radius * (cos, sin), the arc runs from startAngle to startAngle + sweepAngle, and the sign of sweepAngle is its direction. --- .../src/shapes/analytic-solid.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/main/opengeometry-three/src/shapes/analytic-solid.ts b/main/opengeometry-three/src/shapes/analytic-solid.ts index 0da87ab..bdfb7fa 100644 --- a/main/opengeometry-three/src/shapes/analytic-solid.ts +++ b/main/opengeometry-three/src/shapes/analytic-solid.ts @@ -632,6 +632,22 @@ interface LocalBox { hi: [number, number, number]; } +function arcBoxPointsOf(arc: Extract): [number, number][] { + const quarterTurn = Math.PI / 2; + const axisDirections = [[1, 0], [0, 1], [-1, 0], [0, -1]]; + const endAngle = arc.startAngle + arc.sweepAngle; + const points: [number, number][] = [arc.startAngle, endAngle].map((angle) => [ + arc.center[0] + arc.radius * Math.cos(angle), arc.center[1] + arc.radius * Math.sin(angle), + ]); + const firstQuarter = Math.ceil(Math.min(arc.startAngle, endAngle) / quarterTurn); + const lastQuarter = Math.min(Math.floor(Math.max(arc.startAngle, endAngle) / quarterTurn), firstQuarter + 3); + for (let quarter = firstQuarter; quarter <= lastQuarter; quarter += 1) { + const direction = axisDirections[((quarter % 4) + 4) % 4]; + points.push([arc.center[0] + arc.radius * direction[0], arc.center[1] + arc.radius * direction[1]]); + } + return points; +} + function pointBoxOf(options: AnalyticPrimitiveOptions): LocalBox | undefined { const points: [number, number, number][] = options.kind === "polygonLoft" ? [...options.lower, ...options.upper] @@ -646,9 +662,7 @@ function pointBoxOf(options: AnalyticPrimitiveOptions): LocalBox | undefined { } else if (options.kind === "arcEdgedExtrusion") { for (const ring of [options.outer, ...(options.holes ?? [])]) { for (const edge of ring) { - const corners: [number, number][] = edge.kind === "line" - ? [edge.from, edge.to] - : [[edge.center[0] - edge.radius, edge.center[1] - edge.radius], [edge.center[0] + edge.radius, edge.center[1] + edge.radius]]; + const corners = edge.kind === "line" ? [edge.from, edge.to] : arcBoxPointsOf(edge); for (const corner of corners) addProfilePoint(corner, options.height); } } From 124f3e7d829f4bba4ddfb67ae195e53861de8a0b Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 17:04:29 +0200 Subject: [PATCH 15/29] test: each axis extreme of an arc decides the size of a body The case of the short arcs beside an extreme did not see a missing extreme: for a thin ring the largest side of the box is along the chord, and the extreme only widens the other side. With the extremes taken out of the box that case still passed, and only the sweep of 270 degrees failed. The case keeps its bodies under the name of what it does prove. The new case closes an arc of 20 degrees across each of the four axis extremes with a slab 200 m deep behind it, so the side along the axis is the largest and ends at the extreme. Shown with two temporary faults in the box of an arc: without the extremes all four are missed; with the directions of 90 and 270 degrees swapped the case fails as well. --- .../tests/accuracy-rule.test.mjs | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/main/opengeometry-three/tests/accuracy-rule.test.mjs b/main/opengeometry-three/tests/accuracy-rule.test.mjs index 9164252..26fc996 100644 --- a/main/opengeometry-three/tests/accuracy-rule.test.mjs +++ b/main/opengeometry-three/tests/accuracy-rule.test.mjs @@ -167,7 +167,7 @@ test("a gently curved wall gets the accuracy of its own box, near and far", (con assert.ok(near < 1e-6); }); -test("the box of an arc holds each axis extreme that its sweep passes", (context) => { +test("a short arc beside an axis extreme gets the box of the arc", (context) => { const cases = [ { start: 80 * degrees, sweep: 20 * degrees, extremes: [[0, 1]] }, { start: -10 * degrees, sweep: 20 * degrees, extremes: [[1, 0]] }, @@ -196,3 +196,32 @@ test("an arc gives the same box in both directions", () => { assertRelative(curvedWall({ ...wall, clockwise: true }), expected, 1e-9, "clockwise ring"); } }); + +test("the box of an arc reaches each axis extreme that its sweep passes", (context) => { + const [radius, halfSweep, depth] = [80, 10 * degrees, 200]; + const missed = []; + for (const quarter of [0, 1, 2, 3]) { + const middle = quarter * 90 * degrees; + const turned = ([along, across]) => [ + along * Math.cos(middle) - across * Math.sin(middle), + along * Math.sin(middle) + across * Math.cos(middle), + ]; + const halfChord = radius * Math.sin(halfSweep); + const arcEnd = (side) => turned([radius * Math.cos(halfSweep), side * halfChord]); + const body = keep(new AnalyticSolid({ + kind: "arcEdgedExtrusion", + frame: { origin: [0, 0, 0], ...consumerAxes }, + outer: [ + { kind: "arc", center: [0, 0], radius, startAngle: middle - halfSweep, sweepAngle: 2 * halfSweep }, + { kind: "line", from: arcEnd(1), to: turned([-depth, halfChord]) }, + { kind: "line", from: turned([-depth, halfChord]), to: turned([-depth, -halfChord]) }, + { kind: "line", from: turned([-depth, -halfChord]), to: arcEnd(-1) }, + ], + height: 3, + })); + const geometric = body.getBrepData().accuracy.geometric; + context.diagnostic(`arc across the extreme at ${quarter * 90} degrees: geometric ${geometric}`); + if (Math.abs(geometric - (radius + depth) * 1e-8) > 1e-9 * geometric) missed.push(quarter * 90); + } + assert.deepEqual(missed, []); +}); From 983cdc9ca7c78d47e1a026a3a2202d2cb0b17a87 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 17:21:55 +0200 Subject: [PATCH 16/29] test: an arc with a huge or infinite angle does not hang the wrapper The box of an arc steps through the quarter turns between its two angles. From about 1.41e16 rad the step "quarter += 1" no longer changes the number, so the loop never ends and memory grows; an infinite angle makes the wrapper throw a TypeError where the kernel has its own error. Neither happened before the size rule. Each case makes the body in a process of its own, with a time limit and a small heap, so a loop that does not end is a failure and not a hang of the test run. The case fails on this commit: the four huge angles give no result (the process runs out of memory) and the two infinite ones give the TypeError. --- .../tests/accuracy-rule.test.mjs | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/main/opengeometry-three/tests/accuracy-rule.test.mjs b/main/opengeometry-three/tests/accuracy-rule.test.mjs index 26fc996..59159ec 100644 --- a/main/opengeometry-three/tests/accuracy-rule.test.mjs +++ b/main/opengeometry-three/tests/accuracy-rule.test.mjs @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, before, test } from "node:test"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { AnalyticSolid, OpenGeometry } from "../../../dist/index.js"; const distFolder = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist"); @@ -225,3 +226,45 @@ test("the box of an arc reaches each axis extreme that its sweep passes", (conte } assert.deepEqual(missed, []); }); + +function arcBodyInItsOwnProcess(startAngle, sweepAngle) { + const script = ` + import { readFile } from "node:fs/promises"; + import { AnalyticSolid, OpenGeometry } from ${JSON.stringify(pathToFileURL(path.join(distFolder, "index.js")).href)}; + globalThis.document ??= { createElement: () => ({ style: {}, setAttribute() {}, appendChild() {} }) }; + await OpenGeometry.create({ wasmURL: await readFile(${JSON.stringify(path.join(distFolder, "opengeometry", "pkg", "opengeometry_bg.wasm"))}) }); + const [startAngle, sweepAngle] = process.argv.slice(-2).map(Number); + const at = (angle) => [2 * Math.cos(angle), 2 * Math.sin(angle)]; + try { + new AnalyticSolid({ + kind: "arcEdgedExtrusion", + outer: [ + { kind: "arc", center: [0, 0], radius: 2, startAngle, sweepAngle }, + { kind: "line", from: at(startAngle + sweepAngle), to: at(startAngle) }, + ], + height: 3, + }).dispose(); + console.log("made"); + } catch (error) { + console.log(typeof error === "string" ? "kernel error " + error : "wrapper error " + String(error)); + } + `; + return spawnSync( + process.execPath, + ["--max-old-space-size=256", "--input-type=module", "-e", script, String(startAngle), String(sweepAngle)], + { encoding: "utf8", timeout: 20000 }, + ); +} + +test("an arc with a huge or infinite angle ends with a body or with the error of the kernel", (context) => { + const outcomes = [[1.5e16, 1], [1e17, 1], [1e300, 1], [0, -1e17], [Infinity, 1], [0, -Infinity]].map( + ([startAngle, sweepAngle]) => { + const child = arcBodyInItsOwnProcess(startAngle, sweepAngle); + const outcome = child.status === 0 ? child.stdout.trim() : `no result (status ${child.status}, signal ${child.signal})`; + context.diagnostic(`start ${startAngle}, sweep ${sweepAngle}: ${outcome.slice(0, 90)}`); + return outcome; + }, + ); + for (const outcome of outcomes) assert.match(outcome, /^(made|kernel error )/); + for (const outcome of outcomes.slice(-2)) assert.match(outcome, /^kernel error /); +}); From 2ab43a9d3332a86792c0ee6ec4bf51fdc4d0fe2c Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 17:22:09 +0200 Subject: [PATCH 17/29] fix(three): bound the walk over the quarter turns of an arc by a step count The loop over the axis extremes of an arc counted quarter turns. For an angle of about 1.41e16 rad or more, adding one to the quarter does not change the number, so the loop did not end; for an infinite angle the quarter was not a number and the wrapper threw a TypeError. The loop now counts at most four steps from the first quarter, and it runs no step when the first quarter is not finite. The body then goes to the kernel, which accepts it or rejects it with its own error. --- main/opengeometry-three/src/shapes/analytic-solid.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/main/opengeometry-three/src/shapes/analytic-solid.ts b/main/opengeometry-three/src/shapes/analytic-solid.ts index bdfb7fa..91bce3f 100644 --- a/main/opengeometry-three/src/shapes/analytic-solid.ts +++ b/main/opengeometry-three/src/shapes/analytic-solid.ts @@ -640,9 +640,11 @@ function arcBoxPointsOf(arc: Extract): [nu arc.center[0] + arc.radius * Math.cos(angle), arc.center[1] + arc.radius * Math.sin(angle), ]); const firstQuarter = Math.ceil(Math.min(arc.startAngle, endAngle) / quarterTurn); - const lastQuarter = Math.min(Math.floor(Math.max(arc.startAngle, endAngle) / quarterTurn), firstQuarter + 3); - for (let quarter = firstQuarter; quarter <= lastQuarter; quarter += 1) { - const direction = axisDirections[((quarter % 4) + 4) % 4]; + const lastStep = Number.isFinite(firstQuarter) + ? Math.min(Math.floor(Math.max(arc.startAngle, endAngle) / quarterTurn) - firstQuarter, 3) + : -1; + for (let step = 0; step <= lastStep; step += 1) { + const direction = axisDirections[(((firstQuarter + step) % 4) + 4) % 4]; points.push([arc.center[0] + arc.radius * direction[0], arc.center[1] + arc.radius * direction[1]]); } return points; From caa4909d3d87c844a2e96bf3836ad2353cd2013d Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 17:22:54 +0200 Subject: [PATCH 18/29] test: a nearly straight arc body passes the placement check under a matrix The kernel bounds the two cap faces of an arc-edged extrusion with the whole circle of each arc of the outer loop (analytic/primitives.rs, the loop over loops[0].edges), and the placement check reads those bounds. The wrapper takes its coordinate floor from the exact box of the arc, so for a short arc of a large circle the floor is below what the kernel measures. Two bodies under a matrix that is not the identity: a 5 m chord of a circle of radius 1.6e6 m, and a 0.05 m chord of a circle of radius 3.2e4 m. Both fail on this commit with "placement coordinate precision exceeds geometric tolerance". --- .../tests/accuracy-rule.test.mjs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/main/opengeometry-three/tests/accuracy-rule.test.mjs b/main/opengeometry-three/tests/accuracy-rule.test.mjs index 59159ec..94730f3 100644 --- a/main/opengeometry-three/tests/accuracy-rule.test.mjs +++ b/main/opengeometry-three/tests/accuracy-rule.test.mjs @@ -268,3 +268,30 @@ test("an arc with a huge or infinite angle ends with a body or with the error of for (const outcome of outcomes) assert.match(outcome, /^(made|kernel error )/); for (const outcome of outcomes.slice(-2)) assert.match(outcome, /^kernel error /); }); + +test("a nearly straight arc body with a matrix passes the placement check of the kernel", (context) => { + const rejected = []; + for (const [chord, radius, thickness, height] of [[5, 1.6e6, 0.2, 3], [0.05, 3.2e4, 0.02, 0.05]]) { + const halfSweep = Math.asin(chord / 2 / radius); + const arcEnd = (side) => [-radius + radius * Math.cos(side * halfSweep), radius * Math.sin(side * halfSweep)]; + const body = movedOffIdentity(keep(new AnalyticSolid({ + kind: "arcEdgedExtrusion", + frame: { origin: [0, 0, 0], ...consumerAxes }, + outer: [ + { kind: "arc", center: [-radius, 0], radius, startAngle: -halfSweep, sweepAngle: 2 * halfSweep }, + { kind: "line", from: arcEnd(1), to: [-thickness, arcEnd(1)[1]] }, + { kind: "line", from: [-thickness, arcEnd(1)[1]], to: [-thickness, arcEnd(-1)[1]] }, + { kind: "line", from: [-thickness, arcEnd(-1)[1]], to: arcEnd(-1) }, + ], + height, + }))); + const local = body.getBrepData().accuracy.geometric; + context.diagnostic(`chord ${chord} m of a circle of radius ${radius} m: geometric ${local}`); + try { + assert.equal(JSON.parse(body.getWorldBrepSerialized()).accuracy.geometric, local); + } catch (error) { + rejected.push(`chord ${chord} m: ${String(error)}`); + } + } + assert.deepEqual(rejected, []); +}); From c89836d530b24992cec09ddd7e995c6767419df1 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 17:23:21 +0200 Subject: [PATCH 19/29] fix(three): take the reach of an arc-edged body from the circles of its outer arcs The kernel bounds the cap faces of an arc-edged extrusion with the whole circle of each arc of the outer loop, and its placement check takes the magnitude of the body from those bounds. The coordinate floor of the wrapper came from the exact box of the arcs, so a short arc of a large circle got a floor below the check and could not be placed. The size of the body still comes from the exact box. Only the reach now widens that box by centre plus and minus radius for each arc of the outer loop, as the kernel does. For a wall of radius 80 m this floor is about 5e-12, so its accuracy does not change. --- .../src/shapes/analytic-solid.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/main/opengeometry-three/src/shapes/analytic-solid.ts b/main/opengeometry-three/src/shapes/analytic-solid.ts index 91bce3f..8648282 100644 --- a/main/opengeometry-three/src/shapes/analytic-solid.ts +++ b/main/opengeometry-three/src/shapes/analytic-solid.ts @@ -680,6 +680,19 @@ function pointBoxOf(options: AnalyticPrimitiveOptions): LocalBox | undefined { return box; } +function reachBoxOf(options: AnalyticPrimitiveOptions, box: LocalBox): LocalBox { + if (options.kind !== "arcEdgedExtrusion") return box; + const reachBox: LocalBox = { lo: [box.lo[0], box.lo[1], box.lo[2]], hi: [box.hi[0], box.hi[1], box.hi[2]] }; + for (const edge of options.outer) { + if (edge.kind === "line") continue; + for (const axis of [0, 1]) { + reachBox.lo[axis] = Math.min(reachBox.lo[axis], edge.center[axis] - edge.radius); + reachBox.hi[axis] = Math.max(reachBox.hi[axis], edge.center[axis] + edge.radius); + } + } + return reachBox; +} + function worldReachOf(frame: AnalyticFrame, box: LocalBox): number { const farthest = [0, 0, 0]; for (const alongX of [box.lo[0], box.hi[0]]) { @@ -726,7 +739,7 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string): : options.kind === "sphere" ? 2 * options.radius : Math.max(2 * options.radius, options.height); - const coordinateFloor = 128 * Number.EPSILON * worldReachOf(frame, pointBox ?? { lo: [-size, -size, -size], hi: [size, size, size] }); + const coordinateFloor = 128 * Number.EPSILON * worldReachOf(frame, pointBox ? reachBoxOf(options, pointBox) : { lo: [-size, -size, -size], hi: [size, size, size] }); const geometric = Math.max(1e-9, size * 1e-8, coordinateFloor); const accuracy = options.accuracy ?? { geometric, intersection: geometric / 4, tessellation: 0.001, exchange: 0.00001, From 5db39fc166a5997b32143bebc7d3db885d324adb Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 17:24:20 +0200 Subject: [PATCH 20/29] test: cover the height, the two vertex kinds, a moved profile and a turned frame Five faults in the size rule passed every case. Each now has a case that fails with it, shown with the fault put in and taken out again: - a column 0.3 x 0.3 x 3 m gets 3e-8 (the height left out of the box); - a polygon loft and a planar polyhedron get 4e-8 at the origin and at 5000 m (either kind back to the largest absolute coordinate); - an arc wall with its centre at (5000, 5000) in the profile equals the wall moved by its frame (the arc extremes without the centre); - a wall in a frame turned by 45 degrees gets 5e-8 (the size measured along a world axis); - the far cuboid has the floor 128 * EPSILON * hypot(3e6 + 1, 1, 3e6 + 1), not only a world accuracy equal to the local one (the floor with the factor 64). All pass on this commit; the code is unchanged. --- .../tests/accuracy-rule.test.mjs | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/main/opengeometry-three/tests/accuracy-rule.test.mjs b/main/opengeometry-three/tests/accuracy-rule.test.mjs index 94730f3..51f989e 100644 --- a/main/opengeometry-three/tests/accuracy-rule.test.mjs +++ b/main/opengeometry-three/tests/accuracy-rule.test.mjs @@ -94,6 +94,7 @@ test("a cuboid far on the diagonal with a matrix passes the placement check of t const world = JSON.parse(body.getWorldBrepSerialized()).accuracy.geometric; context.diagnostic(`1 m cuboid at (3e6, 0, 3e6): geometric ${world}`); assert.equal(world, body.getBrepData().accuracy.geometric); + assertRelative(world, 128 * Number.EPSILON * Math.hypot(3e6 + 1, 1, 3e6 + 1), 1e-12, "floor of the far cuboid"); }); test("an explicit accuracy passes through unchanged", () => { @@ -125,10 +126,10 @@ test("a door cut of a far wall equals the cut at the origin after the translatio const degrees = Math.PI / 180; -function curvedWall({ start, sweep, origin = [0, 0, 0], clockwise = false }) { +function curvedWall({ start, sweep, origin = [0, 0, 0], center = [0, 0], clockwise = false }) { const [inner, outer] = [80, 80.3]; - const at = (radius, angle) => [radius * Math.cos(angle), radius * Math.sin(angle)]; - const arc = (radius, startAngle, sweepAngle) => ({ kind: "arc", center: [0, 0], radius, startAngle, sweepAngle }); + const at = (radius, angle) => [center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]; + const arc = (radius, startAngle, sweepAngle) => ({ kind: "arc", center, radius, startAngle, sweepAngle }); const ring = [ arc(outer, start, sweep), { kind: "line", from: at(outer, start + sweep), to: at(inner, start + sweep) }, @@ -295,3 +296,47 @@ test("a nearly straight arc body with a matrix passes the placement check of the } assert.deepEqual(rejected, []); }); + +test("a column gets the accuracy of its height", () => { + assertRelative(prism(-0.15, -0.15, 0.3, 0.3, 3).getBrepData().accuracy.geometric, 3 * 1e-8, 1e-12, "column"); +}); + +test("a polygon loft and a planar polyhedron get the accuracy of their largest side at every distance", (context) => { + const moved = (points, shift) => points.map((point) => [point[0] + shift, point[1] + shift, point[2]]); + for (const shift of [0, 5000]) { + const loft = keep(new AnalyticSolid({ + kind: "polygonLoft", + lower: moved([[0, 0, 0], [4, 0, 0], [4, 3, 0], [0, 3, 0]], shift), + upper: moved([[0, 0, 2.5], [4, 0, 2.5], [4, 3, 2.5], [0, 3, 2.5]], shift), + })).getBrepData().accuracy.geometric; + const polyhedron = keep(new AnalyticSolid({ + kind: "planarPolyhedron", + vertices: moved([[0, 0, 0], [4, 0, 0], [0, 3, 0], [0, 0, 2]], shift), + faces: [[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]], + })).getBrepData().accuracy.geometric; + context.diagnostic(`shift ${shift} m: loft ${loft}, polyhedron ${polyhedron}`); + assertRelative(loft, 4 * 1e-8, 1e-9, `polygon loft shifted by ${shift} m`); + assertRelative(polyhedron, 4 * 1e-8, 1e-9, `planar polyhedron shifted by ${shift} m`); + } +}); + +test("an arc wall moved in its profile equals the wall moved by its frame", (context) => { + const wall = { start: 80 * degrees, sweep: 20 * degrees, extremes: [[0, 1]] }; + const byFrame = curvedWall({ ...wall, origin: [5000, 0, -5000] }); + const byProfile = curvedWall({ ...wall, center: [5000, 5000] }); + context.diagnostic(`moved by the frame ${byFrame}, moved in the profile ${byProfile}`); + assertRelative(byProfile, byFrame, 1e-9, "arc wall moved in its profile against the frame"); + assertRelative(byProfile, boxRuleOf(wall), 1e-9, "arc wall moved in its profile"); +}); + +test("a wall in a turned frame gets the accuracy of its own length", () => { + const turn = Math.SQRT1_2; + const body = keep(new AnalyticSolid({ + kind: "linearExtrusion", + frame: { origin: [5000, 0, 5000], x: [turn, 0, -turn], y: [-turn, 0, -turn], z: [0, 1, 0] }, + outer: [[0, 0], [5, 0], [5, 0.2], [0, 0.2]], + holes: [], + height: 3, + })); + assertRelative(body.getBrepData().accuracy.geometric, 5 * 1e-8, 1e-9, "wall in a frame turned by 45 degrees"); +}); From 371d7d6bbadb46299739e207ea14b11638ecd061 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 18:11:23 +0200 Subject: [PATCH 21/29] test: the kernel memory grows in steps, and the size of a reserve The kernel sets no allocator, so the standard one asks the engine for one 64 KiB page at each need. A large model grows the memory thousands of times, and each growth costs more the larger the memory is. Two tests come before the allocator. The unit test states the size of the reserve as a pure function of the memory in pages: one eighth, at least 1 MiB, at most 64 MiB, and no overflow at 65,536 pages on a 32-bit usize. The function does not exist yet, so the test build fails on this commit. The wrapper test boots the built package, keeps 5000 cuboid bodies and counts the growth steps of the memory. On this commit: 828 steps, where the case allows 100. Its three other cases pass already and are guards: the wasm has one memory of its own, not shared and without a maximum; the memory after the bodies is at most one eighth and one step above the 55,508,992 bytes of today; one body needs at most 4 MiB. The module is declared for wasm32 and for tests, so the native build compiles nothing of it outside the unit test. --- .../tests/memory-growth.test.mjs | 125 ++++++++++++++++++ main/opengeometry/src/lib.rs | 3 + main/opengeometry/src/memory.rs | 18 +++ 3 files changed, 146 insertions(+) create mode 100644 main/opengeometry-three/tests/memory-growth.test.mjs create mode 100644 main/opengeometry/src/memory.rs diff --git a/main/opengeometry-three/tests/memory-growth.test.mjs b/main/opengeometry-three/tests/memory-growth.test.mjs new file mode 100644 index 0000000..c11ef9d --- /dev/null +++ b/main/opengeometry-three/tests/memory-growth.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { after, before, test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const packageFolder = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist/opengeometry/pkg"); +const PAGE_BYTES = 65536; +const MEBIBYTE = 1024 * 1024; +const BODY_COUNT = 5000; +const BYTES_AFTER_THE_BODIES_WITHOUT_A_RESERVE = 55508992; +const keptBodies = []; +const measured = { wasmBytes: undefined, afterOneBody: 0, afterAllBodies: 0, growthSteps: 0 }; + +before(async () => { + measured.wasmBytes = await readFile(path.join(packageFolder, "opengeometry_bg.wasm")); + const glue = await import(pathToFileURL(path.join(packageFolder, "opengeometry.js")).href); + let memory; + const instantiate = globalThis.WebAssembly.instantiate; + globalThis.WebAssembly.instantiate = async (...parameters) => { + const result = await instantiate(...parameters); + memory = (result.instance ?? result).exports.memory; + return result; + }; + try { + await glue.default({ module_or_path: measured.wasmBytes }); + } finally { + globalThis.WebAssembly.instantiate = instantiate; + } + let lastBytes = memory.buffer.byteLength; + for (let index = 0; index < BODY_COUNT; index += 1) { + keptBodies.push(glue.OGAnalyticBrep.from_primitive(JSON.stringify({ + kind: "cuboid", + id: `body-${index}`, + frame: { origin: [0, 0, 0], x: [1, 0, 0], y: [0, 0, -1], z: [0, 1, 0] }, + accuracy: { geometric: 1e-9, intersection: 1e-10, tessellation: 0.001, exchange: 1e-5 }, + size: [4, 3, 0.2], + }))); + if (memory.buffer.byteLength !== lastBytes) { + measured.growthSteps += 1; + lastBytes = memory.buffer.byteLength; + } + if (index === 0) measured.afterOneBody = lastBytes; + } + measured.afterAllBodies = lastBytes; +}); + +after(() => { + for (const body of keptBodies.splice(0)) body.free(); +}); + +function sectionsOf(bytes) { + let position = 8; + const unsigned = () => { + let value = 0; + let scale = 1; + let byte; + do { + byte = bytes[position]; + position += 1; + value += (byte & 0x7f) * scale; + scale *= 128; + } while (byte & 0x80); + return value; + }; + const limits = () => { + const flags = bytes[position]; + position += 1; + const initial = unsigned(); + return { flags, initial, maximum: flags & 1 ? unsigned() : undefined }; + }; + const memories = []; + const importedMemories = []; + while (position < bytes.length) { + const id = bytes[position]; + position += 1; + const size = unsigned(); + const end = position + size; + if (id === 5) { + for (let count = unsigned(); count > 0; count -= 1) memories.push(limits()); + } + if (id === 2) { + for (let count = unsigned(); count > 0; count -= 1) { + const moduleNameLength = unsigned(); + position += moduleNameLength; + const fieldNameLength = unsigned(); + position += fieldNameLength; + const kind = bytes[position]; + position += 1; + if (kind === 0) unsigned(); + if (kind === 1) { + position += 1; + limits(); + } + if (kind === 2) importedMemories.push(limits()); + if (kind === 3) position += 2; + } + } + position = end; + } + return { memories, importedMemories }; +} + +test("the kernel has one memory of its own, not shared and without a maximum", () => { + const { memories, importedMemories } = sectionsOf(measured.wasmBytes); + assert.equal(importedMemories.length, 0); + assert.equal(memories.length, 1); + assert.equal(memories[0].flags & 2, 0); + assert.equal(memories[0].maximum, undefined); +}); + +test("the memory grows in large steps", (context) => { + context.diagnostic(`${BODY_COUNT} kept bodies: ${measured.growthSteps} growth steps, ${(measured.afterAllBodies / MEBIBYTE).toFixed(2)} MiB (${measured.afterAllBodies} bytes)`); + assert.ok(measured.growthSteps <= 100, `${measured.growthSteps} growth steps`); +}); + +test("the reserve adds at most an eighth and one step to the memory", () => { + const bound = 1.125 * BYTES_AFTER_THE_BODIES_WITHOUT_A_RESERVE + MEBIBYTE + PAGE_BYTES; + assert.ok(measured.afterAllBodies <= bound, `${measured.afterAllBodies} bytes against a bound of ${bound}`); +}); + +test("one body needs at most 4 MiB of memory", (context) => { + context.diagnostic(`one body: ${(measured.afterOneBody / MEBIBYTE).toFixed(2)} MiB`); + assert.ok(measured.afterOneBody <= 4 * MEBIBYTE, `${measured.afterOneBody} bytes`); +}); diff --git a/main/opengeometry/src/lib.rs b/main/opengeometry/src/lib.rs index 71f6b6f..a6be123 100644 --- a/main/opengeometry/src/lib.rs +++ b/main/opengeometry/src/lib.rs @@ -51,3 +51,6 @@ pub mod freeform; pub mod tolerance; pub mod units; pub mod world; + +#[cfg(any(target_arch = "wasm32", test))] +mod memory; diff --git a/main/opengeometry/src/memory.rs b/main/opengeometry/src/memory.rs new file mode 100644 index 0000000..1344f44 --- /dev/null +++ b/main/opengeometry/src/memory.rs @@ -0,0 +1,18 @@ +#[cfg(test)] +mod tests { + use super::reserve_bytes; + + const MEBIBYTE: usize = 1024 * 1024; + + #[test] + fn reserve_is_an_eighth_of_the_memory_between_one_and_sixty_four_mebibytes() { + assert_eq!(reserve_bytes(19), MEBIBYTE); + assert_eq!(reserve_bytes(2560), 20 * MEBIBYTE); + assert_eq!(reserve_bytes(16_384), 64 * MEBIBYTE); + } + + #[test] + fn reserve_of_a_full_address_space_does_not_overflow() { + assert_eq!(reserve_bytes(65_536), 64 * MEBIBYTE); + } +} From 7ca56b47ba58eb18f2d2321d2a1af3e440b0224d Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 18:13:58 +0200 Subject: [PATCH 22/29] test: the size of a reserve is bounded for every page count The case for 65,536 pages cannot fail on a 64-bit host, where the product of the pages and the page size fits a usize in any order of the arithmetic. A page count of usize::MAX overflows on every host unless the function is total, so this case fails for an arithmetic that can overflow, on the machine that runs the unit tests. The function still does not exist, so the test build fails on this commit as before. --- main/opengeometry/src/memory.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/main/opengeometry/src/memory.rs b/main/opengeometry/src/memory.rs index 1344f44..597cc5d 100644 --- a/main/opengeometry/src/memory.rs +++ b/main/opengeometry/src/memory.rs @@ -15,4 +15,10 @@ mod tests { fn reserve_of_a_full_address_space_does_not_overflow() { assert_eq!(reserve_bytes(65_536), 64 * MEBIBYTE); } + + #[test] + fn reserve_is_bounded_for_every_page_count() { + assert_eq!(reserve_bytes(0), MEBIBYTE); + assert_eq!(reserve_bytes(usize::MAX), 64 * MEBIBYTE); + } } From f1229a6fd6cedee1d80393fbd295b54c9fd7c16c Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 18:14:43 +0200 Subject: [PATCH 23/29] perf(memory): grow the WebAssembly memory in steps, not in pages The standard allocator of wasm32 asks the engine for one 64 KiB page at each need and never gives a page back. A model of 2560 rooms grew the memory 11,129 times, and in V8 one growth costs more the larger the memory is: 9.4 s of that open. A global allocator for wasm32 now wraps the system allocator. After a call that grew the memory it allocates and frees one block of one eighth of the memory, at least 1 MiB and at most 64 MiB. The freed block borders the top of the heap and merges into it, so the next allocations need no growth. The block has the alignment 8, the allocator's own on wasm32, so it is one plain chunk. This is the first unsafe code of the crate. Each unsafe block is one call into the system allocator: the four entries pass on the arguments of their caller, and the reserve frees the block that it has just got with the same layout. The size of the reserve is a pure function that is total, with a unit test on every target. The entries stay out of line and share one alloc and one dealloc entry; inlined, the wasm was 246 KB larger. When the engine refuses a reserve, a flag stops all further reserves. The allocation of the caller has already succeeded, so nothing fails and nothing is logged; the kernel then grows page by page as before. The reserve is address space: the memory that the process touches does not change. No setting, no dependency, no linker argument. --- main/opengeometry/src/lib.rs | 4 ++ main/opengeometry/src/memory.rs | 89 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/main/opengeometry/src/lib.rs b/main/opengeometry/src/lib.rs index a6be123..7171936 100644 --- a/main/opengeometry/src/lib.rs +++ b/main/opengeometry/src/lib.rs @@ -54,3 +54,7 @@ pub mod world; #[cfg(any(target_arch = "wasm32", test))] mod memory; + +#[cfg(target_arch = "wasm32")] +#[global_allocator] +static ALLOCATOR: memory::GrowAhead = memory::GrowAhead; diff --git a/main/opengeometry/src/memory.rs b/main/opengeometry/src/memory.rs index 597cc5d..c4f9818 100644 --- a/main/opengeometry/src/memory.rs +++ b/main/opengeometry/src/memory.rs @@ -1,3 +1,92 @@ +const PAGE_BYTES: usize = 65_536; +const RESERVE_DIVISOR: usize = 8; +const LEAST_RESERVE: usize = 1024 * 1024; +const MOST_RESERVE: usize = 64 * 1024 * 1024; + +fn reserve_bytes(pages: usize) -> usize { + (pages / RESERVE_DIVISOR) + .saturating_mul(PAGE_BYTES) + .clamp(LEAST_RESERVE, MOST_RESERVE) +} + +#[cfg(target_arch = "wasm32")] +pub use grow_ahead::GrowAhead; + +#[cfg(target_arch = "wasm32")] +mod grow_ahead { + use super::reserve_bytes; + use core::arch::wasm32::memory_size; + use core::sync::atomic::{AtomicBool, Ordering}; + use std::alloc::{GlobalAlloc, Layout, System}; + + const RESERVE_ALIGNMENT: usize = 8; + + static RESERVE_OFF: AtomicBool = AtomicBool::new(false); + + pub struct GrowAhead; + + #[inline(never)] + unsafe fn system_alloc(layout: Layout) -> *mut u8 { + unsafe { System.alloc(layout) } + } + + #[inline(never)] + unsafe fn system_dealloc(block: *mut u8, layout: Layout) { + unsafe { System.dealloc(block, layout) } + } + + #[inline(never)] + fn reserve(bytes: usize) { + let Ok(layout) = Layout::from_size_align(bytes, RESERVE_ALIGNMENT) else { + return; + }; + let block = unsafe { system_alloc(layout) }; + if block.is_null() { + RESERVE_OFF.store(true, Ordering::Relaxed); + } else { + unsafe { system_dealloc(block, layout) }; + } + } + + fn reserve_after_growth(pages_before: usize) { + let pages = memory_size::<0>(); + if pages != pages_before && !RESERVE_OFF.load(Ordering::Relaxed) { + reserve(reserve_bytes(pages)); + } + } + + unsafe impl GlobalAlloc for GrowAhead { + #[inline(never)] + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let pages = memory_size::<0>(); + let block = unsafe { system_alloc(layout) }; + reserve_after_growth(pages); + block + } + + #[inline(never)] + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + let pages = memory_size::<0>(); + let block = unsafe { System.alloc_zeroed(layout) }; + reserve_after_growth(pages); + block + } + + #[inline(never)] + unsafe fn dealloc(&self, block: *mut u8, layout: Layout) { + unsafe { system_dealloc(block, layout) }; + } + + #[inline(never)] + unsafe fn realloc(&self, block: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let pages = memory_size::<0>(); + let moved = unsafe { System.realloc(block, layout, new_size) }; + reserve_after_growth(pages); + moved + } + } +} + #[cfg(test)] mod tests { use super::reserve_bytes; From 72549d74a925ab4cfd4a839486b59b7ec7583a1c Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 18:38:47 +0200 Subject: [PATCH 24/29] fix(memory): limit the allocator to wasm32 without an operating system The gate target_arch = "wasm32" is also true for WASI and Emscripten, where the system allocator is another one and the reserve is not proven. The reserve relies on the allocator of wasm32-unknown-unknown, so the gate now also asks for target_os = "unknown". The build for the browser compiles the same code as before: its wasm has the same bytes. --- main/opengeometry/src/lib.rs | 4 ++-- main/opengeometry/src/memory.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/main/opengeometry/src/lib.rs b/main/opengeometry/src/lib.rs index 7171936..35e84a8 100644 --- a/main/opengeometry/src/lib.rs +++ b/main/opengeometry/src/lib.rs @@ -52,9 +52,9 @@ pub mod tolerance; pub mod units; pub mod world; -#[cfg(any(target_arch = "wasm32", test))] +#[cfg(any(all(target_arch = "wasm32", target_os = "unknown"), test))] mod memory; -#[cfg(target_arch = "wasm32")] +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] #[global_allocator] static ALLOCATOR: memory::GrowAhead = memory::GrowAhead; diff --git a/main/opengeometry/src/memory.rs b/main/opengeometry/src/memory.rs index c4f9818..5c51df6 100644 --- a/main/opengeometry/src/memory.rs +++ b/main/opengeometry/src/memory.rs @@ -9,10 +9,10 @@ fn reserve_bytes(pages: usize) -> usize { .clamp(LEAST_RESERVE, MOST_RESERVE) } -#[cfg(target_arch = "wasm32")] +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] pub use grow_ahead::GrowAhead; -#[cfg(target_arch = "wasm32")] +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] mod grow_ahead { use super::reserve_bytes; use core::arch::wasm32::memory_size; From 1169de6bc064f7d7814aa5993a02ea0d9882794a Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 18:42:18 +0200 Subject: [PATCH 25/29] test: pin the true worst case of the reserve, two reserves and two pages "At most one eighth more memory" holds only for small allocations. When one request is larger than the free top, the memory grows by the whole request and the old top stays free; if the next reserve does not fit that old top, one more reserve is added. The free top can then hold two reserves, about one quarter of the memory. The case that bounded the 5000 small bodies now says in its title what it bounds. A new case makes the worst pattern in a kernel of its own: 1 KiB blocks up to a growth after 16 MiB, then one block of a reserve and two pages. It requires more than one reserve above the memory without a reserve (5.56 MiB, 1.62 reserves, 25.1 %) and at most two reserves and two pages. The memory without a reserve comes from a small model of the standard allocator, which gives the page count of the build before the allocator exactly for this pattern. With one quarter as the reserve this case fails; with no reserve it fails too. The three memory test files share one boot helper. --- .../tests/kernel-memory.mjs | 36 +++++++++++++++ .../tests/memory-growth.test.mjs | 37 +++++---------- .../tests/memory-worst-case.test.mjs | 45 +++++++++++++++++++ 3 files changed, 91 insertions(+), 27 deletions(-) create mode 100644 main/opengeometry-three/tests/kernel-memory.mjs create mode 100644 main/opengeometry-three/tests/memory-worst-case.test.mjs diff --git a/main/opengeometry-three/tests/kernel-memory.mjs b/main/opengeometry-three/tests/kernel-memory.mjs new file mode 100644 index 0000000..99fafb8 --- /dev/null +++ b/main/opengeometry-three/tests/kernel-memory.mjs @@ -0,0 +1,36 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const PAGE_BYTES = 65536; +export const MEBIBYTE = 1024 * 1024; + +const packageFolder = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist/opengeometry/pkg"); + +export function reserveBytes(pages) { + return Math.min(Math.max(Math.floor(pages / 8) * PAGE_BYTES, MEBIBYTE), 64 * MEBIBYTE); +} + +export async function bootKernel() { + const wasmBytes = await readFile(path.join(packageFolder, "opengeometry_bg.wasm")); + const glue = await import(pathToFileURL(path.join(packageFolder, "opengeometry.js")).href); + let kernelExports; + const instantiate = globalThis.WebAssembly.instantiate; + globalThis.WebAssembly.instantiate = async (...parameters) => { + const result = await instantiate(...parameters); + kernelExports = (result.instance ?? result).exports; + return result; + }; + try { + await glue.default({ module_or_path: wasmBytes }); + } finally { + globalThis.WebAssembly.instantiate = instantiate; + } + const memory = kernelExports.memory; + return { + glue, + wasmBytes, + pages: () => memory.buffer.byteLength / PAGE_BYTES, + allocate: (bytes) => kernelExports.__wbindgen_malloc(bytes, 8) >>> 0, + }; +} diff --git a/main/opengeometry-three/tests/memory-growth.test.mjs b/main/opengeometry-three/tests/memory-growth.test.mjs index c11ef9d..da194b8 100644 --- a/main/opengeometry-three/tests/memory-growth.test.mjs +++ b/main/opengeometry-three/tests/memory-growth.test.mjs @@ -1,48 +1,31 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; import { after, before, test } from "node:test"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { bootKernel, MEBIBYTE, PAGE_BYTES } from "./kernel-memory.mjs"; -const packageFolder = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist/opengeometry/pkg"); -const PAGE_BYTES = 65536; -const MEBIBYTE = 1024 * 1024; const BODY_COUNT = 5000; const BYTES_AFTER_THE_BODIES_WITHOUT_A_RESERVE = 55508992; const keptBodies = []; const measured = { wasmBytes: undefined, afterOneBody: 0, afterAllBodies: 0, growthSteps: 0 }; before(async () => { - measured.wasmBytes = await readFile(path.join(packageFolder, "opengeometry_bg.wasm")); - const glue = await import(pathToFileURL(path.join(packageFolder, "opengeometry.js")).href); - let memory; - const instantiate = globalThis.WebAssembly.instantiate; - globalThis.WebAssembly.instantiate = async (...parameters) => { - const result = await instantiate(...parameters); - memory = (result.instance ?? result).exports.memory; - return result; - }; - try { - await glue.default({ module_or_path: measured.wasmBytes }); - } finally { - globalThis.WebAssembly.instantiate = instantiate; - } - let lastBytes = memory.buffer.byteLength; + const kernel = await bootKernel(); + measured.wasmBytes = kernel.wasmBytes; + let lastPages = kernel.pages(); for (let index = 0; index < BODY_COUNT; index += 1) { - keptBodies.push(glue.OGAnalyticBrep.from_primitive(JSON.stringify({ + keptBodies.push(kernel.glue.OGAnalyticBrep.from_primitive(JSON.stringify({ kind: "cuboid", id: `body-${index}`, frame: { origin: [0, 0, 0], x: [1, 0, 0], y: [0, 0, -1], z: [0, 1, 0] }, accuracy: { geometric: 1e-9, intersection: 1e-10, tessellation: 0.001, exchange: 1e-5 }, size: [4, 3, 0.2], }))); - if (memory.buffer.byteLength !== lastBytes) { + if (kernel.pages() !== lastPages) { measured.growthSteps += 1; - lastBytes = memory.buffer.byteLength; + lastPages = kernel.pages(); } - if (index === 0) measured.afterOneBody = lastBytes; + if (index === 0) measured.afterOneBody = lastPages * PAGE_BYTES; } - measured.afterAllBodies = lastBytes; + measured.afterAllBodies = lastPages * PAGE_BYTES; }); after(() => { @@ -114,7 +97,7 @@ test("the memory grows in large steps", (context) => { assert.ok(measured.growthSteps <= 100, `${measured.growthSteps} growth steps`); }); -test("the reserve adds at most an eighth and one step to the memory", () => { +test("many small kept bodies need at most an eighth and one step more memory than without a reserve", () => { const bound = 1.125 * BYTES_AFTER_THE_BODIES_WITHOUT_A_RESERVE + MEBIBYTE + PAGE_BYTES; assert.ok(measured.afterAllBodies <= bound, `${measured.afterAllBodies} bytes against a bound of ${bound}`); }); diff --git a/main/opengeometry-three/tests/memory-worst-case.test.mjs b/main/opengeometry-three/tests/memory-worst-case.test.mjs new file mode 100644 index 0000000..9274705 --- /dev/null +++ b/main/opengeometry-three/tests/memory-worst-case.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { bootKernel, MEBIBYTE, PAGE_BYTES, reserveBytes } from "./kernel-memory.mjs"; + +const roundedUp = (value, step) => Math.ceil(value / step) * step; + +function memoryWithoutAReserve(startPages) { + const heap = { pages: startPages, freeTop: 0, started: false }; + heap.allocate = (bytes) => { + const chunk = Math.max(16, roundedUp(bytes + 4, 8)); + if (!(chunk < heap.freeTop)) { + const grown = roundedUp(chunk + 48, PAGE_BYTES); + heap.pages += grown / PAGE_BYTES; + heap.freeTop += grown - (heap.started ? 0 : 40); + heap.started = true; + } + heap.freeTop -= chunk; + }; + return heap; +} + +test("one block larger than the free top leaves more than one reserve free, and at most two reserves and two pages", async (context) => { + const kernel = await bootKernel(); + const plain = memoryWithoutAReserve(kernel.pages()); + const allocate = (bytes) => { + kernel.allocate(bytes); + plain.allocate(bytes); + }; + let smallBytes = 0; + let pagesBefore = kernel.pages(); + let grew = false; + while (!(grew && smallBytes >= 16 * MEBIBYTE)) { + allocate(1024); + smallBytes += 1024; + grew = kernel.pages() !== pagesBefore; + pagesBefore = kernel.pages(); + } + const blockBytes = reserveBytes(kernel.pages()) + 2 * PAGE_BYTES; + allocate(blockBytes); + const reserve = reserveBytes(kernel.pages()); + const above = (kernel.pages() - plain.pages) * PAGE_BYTES; + context.diagnostic(`${(smallBytes / MEBIBYTE).toFixed(2)} MiB of 1 KiB blocks, then one block of ${(blockBytes / MEBIBYTE).toFixed(2)} MiB: ${kernel.pages()} pages, ${plain.pages} without a reserve, ${(above / MEBIBYTE).toFixed(2)} MiB above = ${(above / reserve).toFixed(2)} reserves of ${(reserve / MEBIBYTE).toFixed(2)} MiB = ${((100 * above) / (plain.pages * PAGE_BYTES)).toFixed(1)} %`); + assert.ok(above > reserve, `${above} bytes above, one reserve is ${reserve}`); + assert.ok(above <= 2 * reserve + 2 * PAGE_BYTES, `${above} bytes above, two reserves and two pages are ${2 * reserve + 2 * PAGE_BYTES}`); +}); From 0d174b26770c18bd0c68d6b81b01ec6ae66c5036 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 18:42:18 +0200 Subject: [PATCH 26/29] test: a refused reserve leaves the kernel working up to the last page The engine refuses a growth beyond 65,536 pages, so the refused reserve can be reached in Node. Large blocks that are never written bring the memory to a few pages below the limit; there the 64 MiB reserve cannot be granted. The case requires that nothing traps, that the last pages come one at a time up to exactly 65,536, and that a small block is still served there. It runs in about 0.1 s and touches about 10 MiB; the 4 GiB are address space. An engine that cannot give 4 GiB of address space skips the case during the first large blocks, where no reserve is refused yet. With a panic in place of the refused branch the case fails. It cannot see the flag that stops further reserves: with or without it the page counts are the same. --- .../tests/memory-limit.test.mjs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 main/opengeometry-three/tests/memory-limit.test.mjs diff --git a/main/opengeometry-three/tests/memory-limit.test.mjs b/main/opengeometry-three/tests/memory-limit.test.mjs new file mode 100644 index 0000000..46085ab --- /dev/null +++ b/main/opengeometry-three/tests/memory-limit.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { bootKernel, MEBIBYTE, PAGE_BYTES } from "./kernel-memory.mjs"; + +const LIMIT_PAGES = 65536; + +test("a refused reserve leaves the kernel working up to the last page of the engine", async (context) => { + const kernel = await bootKernel(); + const pagesLeft = () => LIMIT_PAGES - kernel.pages(); + try { + while (pagesLeft() > 2100) { + kernel.allocate(Math.min(1024 * MEBIBYTE, (pagesLeft() - 2090) * PAGE_BYTES)); + } + } catch (error) { + context.skip(`this engine does not give 4 GiB of address space: ${String(error)}`); + return; + } + while (pagesLeft() > 1023) kernel.allocate((pagesLeft() - 1000) * PAGE_BYTES); + for (const bytes of [16 * MEBIBYTE, 4 * MEBIBYTE, MEBIBYTE, 256 * 1024, 64 * 1024, 16 * 1024, 4096]) { + let fitsTheFreeTop = true; + while (fitsTheFreeTop && bytes / PAGE_BYTES + 2 <= pagesLeft()) { + const pagesBefore = kernel.pages(); + kernel.allocate(bytes); + fitsTheFreeTop = kernel.pages() === pagesBefore; + } + } + if (pagesLeft() > 10) kernel.allocate((pagesLeft() - 8) * PAGE_BYTES); + const pagesAtTheRefusal = kernel.pages(); + const growthSteps = []; + let pagesBefore = pagesAtTheRefusal; + let smallBlocks = 0; + while (kernel.pages() < LIMIT_PAGES) { + kernel.allocate(1024); + smallBlocks += 1; + if (kernel.pages() !== pagesBefore) { + growthSteps.push(kernel.pages() - pagesBefore); + pagesBefore = kernel.pages(); + } + } + context.diagnostic(`${pagesAtTheRefusal} pages before the last small blocks, ${smallBlocks} blocks of 1 KiB, growth steps ${JSON.stringify(growthSteps)}, end ${kernel.pages()} pages`); + assert.ok(pagesAtTheRefusal > LIMIT_PAGES - 1024 && pagesAtTheRefusal < LIMIT_PAGES); + assert.deepEqual(growthSteps, new Array(LIMIT_PAGES - pagesAtTheRefusal).fill(1)); + assert.equal(kernel.pages(), LIMIT_PAGES); + assert.notEqual(kernel.allocate(1024), 0); +}); From 95641a2db43c34e9243aacc9461fe5abdd634044 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 21:34:28 +0200 Subject: [PATCH 27/29] test: probes and first tests for the two defects of the 2D region boolean Step 0 of the fix, a measurement: no production code changes. Every test of this file is ignored, so the suite is as before. Six tests state what the region boolean must do and fail today: two squares with a gap between the tolerance (1e-6) and the probe reach (1e-4) must stay two regions; a strip thinner than the reach must survive, alone and across a square; no result may be an open chain closed by a straight line; a hole must go to the innermost region that holds it, for two and for three levels of nesting. Three probes print the mechanism: how each edge of two squares 1e-5 apart is classed (the two facing edges are dropped, because the probe point at 1e-4 lands inside the other square), which outer rings hold each hole (the first one in output order takes it), and the map of the union by gap. Run them with: cargo test --test region_boolean_probe -- --ignored --nocapture --- .../tests/region_boolean_probe.rs | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 main/opengeometry/tests/region_boolean_probe.rs diff --git a/main/opengeometry/tests/region_boolean_probe.rs b/main/opengeometry/tests/region_boolean_probe.rs new file mode 100644 index 0000000..0d54d96 --- /dev/null +++ b/main/opengeometry/tests/region_boolean_probe.rs @@ -0,0 +1,264 @@ +use opengeometry::geometry::boolean2d::{ + boolean_oriented_regions, regions_from_edges_by, PlanarBooleanOp, RingRegion, +}; +use opengeometry::geometry::poly2d::{ + ring_inside2, signed_area2, winding_number2, Edge2, Pt2, DEFAULT_EPS, +}; + +fn square(x0: f64, z0: f64, x1: f64, z1: f64) -> Vec { + vec![ + Pt2::new(x0, z0), + Pt2::new(x1, z0), + Pt2::new(x1, z1), + Pt2::new(x0, z1), + ] +} + +fn hole(x0: f64, z0: f64, x1: f64, z1: f64) -> Vec { + let mut ring = square(x0, z0, x1, z1); + ring.reverse(); + ring +} + +fn area(regions: &[RingRegion]) -> f64 { + regions + .iter() + .map(|region| { + signed_area2(®ion.outer).abs() + - region + .holes + .iter() + .map(|ring| signed_area2(ring).abs()) + .sum::() + }) + .sum() +} + +fn span(ring: &[Pt2]) -> (f64, f64) { + let low = ring + .iter() + .map(|point| point.x) + .fold(f64::INFINITY, f64::min); + let high = ring + .iter() + .map(|point| point.x) + .fold(f64::NEG_INFINITY, f64::max); + (low, high) +} + +fn hole_spans(regions: &[RingRegion]) -> Vec<((f64, f64), Vec<(f64, f64)>)> { + let mut spans: Vec<_> = regions + .iter() + .map(|region| { + ( + span(®ion.outer), + region.holes.iter().map(|ring| span(ring)).collect(), + ) + }) + .collect(); + spans.sort_by(|first, second| first.0 .0.total_cmp(&second.0 .0)); + spans +} + +fn union(a: &[Vec], b: &[Vec]) -> Vec { + boolean_oriented_regions(a, b, PlanarBooleanOp::Union, DEFAULT_EPS) +} + +#[test] +#[ignore = "defect A: fails until the side of an edge no longer comes from a probe at a fixed reach"] +fn union_keeps_two_squares_whose_gap_is_above_the_tolerance_and_below_the_probe_reach() { + for gap in [2.0e-6, 1.0e-5, 9.0e-5] { + let regions = union( + &[square(0.0, 0.0, 1.0, 1.0)], + &[square(1.0 + gap, 0.0, 2.0 + gap, 1.0)], + ); + assert_eq!(regions.len(), 2, "gap {gap}"); + assert!((area(®ions) - 2.0).abs() < 1.0e-9, "gap {gap}"); + } +} + +#[test] +#[ignore = "defect A: fails until the side of an edge no longer comes from a probe at a fixed reach"] +fn union_keeps_a_strip_that_is_thinner_than_the_probe_reach() { + for width in [3.0e-6, 1.0e-5, 9.0e-5] { + let alone = union(&[square(0.0, 0.0, 1.0, width)], &[]); + assert_eq!(alone.len(), 1, "width {width}"); + assert!((area(&alone) - width).abs() < 1.0e-12, "width {width}"); + } +} + +#[test] +#[ignore = "defect A: fails until the side of an edge no longer comes from a probe at a fixed reach"] +fn a_thin_strip_across_a_square_does_not_erase_the_square() { + for width in [3.0e-6, 1.0e-5, 9.0e-5] { + let unit = [square(0.0, 0.0, 1.0, 1.0)]; + let strip = [square(-0.5, 0.5, 1.5, 0.5 + width)]; + let united = union(&unit, &strip); + assert_eq!(united.len(), 1, "width {width}"); + assert!( + (area(&united) - (1.0 + width)).abs() < 1.0e-9, + "width {width}" + ); + let cut = + boolean_oriented_regions(&unit, &strip, PlanarBooleanOp::Subtraction, DEFAULT_EPS); + assert_eq!(cut.len(), 2, "width {width}"); + assert!((area(&cut) - (1.0 - width)).abs() < 1.0e-9, "width {width}"); + } +} + +#[test] +#[ignore = "defect A: fails until an open chain of boundary edges is no longer returned as a ring"] +fn a_result_is_never_an_open_chain_closed_by_a_straight_line() { + let regions = union( + &[square(0.0, 0.0, 1.0, 1.0)], + &[square(1.0 + 1.0e-5, 0.0, 2.0 + 1.0e-5, 1.0)], + ); + for region in ®ions { + assert!( + region.outer.len() >= 4, + "a ring of {} points with area {}", + region.outer.len(), + signed_area2(®ion.outer) + ); + } +} + +#[test] +#[ignore = "defect B: fails until a hole goes to the innermost region that holds it"] +fn union_gives_each_hole_to_the_innermost_region_that_holds_it() { + let frame = [square(0.0, 0.0, 10.0, 10.0), hole(1.0, 1.0, 9.0, 9.0)]; + let island = [square(3.0, 3.0, 7.0, 7.0), hole(4.0, 4.0, 6.0, 6.0)]; + let expected = vec![ + ((0.0, 10.0), vec![(1.0, 9.0)]), + ((3.0, 7.0), vec![(4.0, 6.0)]), + ]; + assert_eq!(hole_spans(&union(&frame, &island)), expected); + assert_eq!(hole_spans(&union(&island, &frame)), expected); +} + +#[test] +#[ignore = "defect B: fails until a hole goes to the innermost region that holds it"] +fn three_nested_rings_keep_their_own_holes_in_every_operation() { + let level = |low: f64, high: f64| { + vec![ + square(low, low, high, high), + hole(low + 1.0, low + 1.0, high - 1.0, high - 1.0), + ] + }; + let outer = level(0.0, 20.0); + let inner: Vec> = level(3.0, 17.0) + .into_iter() + .chain(level(6.0, 14.0)) + .chain(std::iter::once(square(9.0, 9.0, 11.0, 11.0))) + .collect(); + let expected = vec![ + ((0.0, 20.0), vec![(1.0, 19.0)]), + ((3.0, 17.0), vec![(4.0, 16.0)]), + ((6.0, 14.0), vec![(7.0, 13.0)]), + ((9.0, 11.0), vec![]), + ]; + assert_eq!(hole_spans(&union(&outer, &inner)), expected); + let all: Vec> = outer.iter().chain(&inner).cloned().collect(); + let cover = [square(0.0, 0.0, 20.0, 20.0)]; + let kept = boolean_oriented_regions(&all, &cover, PlanarBooleanOp::Intersection, DEFAULT_EPS); + assert_eq!(hole_spans(&kept), expected); + for region in &kept { + assert!(area(std::slice::from_ref(region)) > 0.0); + } +} + +#[test] +#[ignore = "probe: prints how each edge of two squares 1e-5 apart is classed"] +fn probe_defect_a_the_two_facing_edges_are_classed_as_interior() { + let gap = 1.0e-5; + let a = square(0.0, 0.0, 1.0, 1.0); + let b = square(1.0 + gap, 0.0, 2.0 + gap, 1.0); + let edges: Vec = [&a, &b] + .into_iter() + .flat_map(|ring| (0..4).map(move |index| (ring[index], ring[(index + 1) % 4]))) + .collect(); + let calls = std::cell::RefCell::new(Vec::new()); + let regions = regions_from_edges_by(&edges, DEFAULT_EPS, |point| { + let filled = winding_number2(point, &a) != 0 || winding_number2(point, &b) != 0; + calls.borrow_mut().push((point, filled)); + filled + }); + for (edge, pair) in edges.iter().zip(calls.borrow().chunks(2)) { + println!( + "edge ({}, {}) -> ({}, {}): left probe ({:.6}, {:.6}) filled {}, right probe ({:.6}, {:.6}) filled {} => {}", + edge.0.x, edge.0.z, edge.1.x, edge.1.z, + pair[0].0.x, pair[0].0.z, pair[0].1, + pair[1].0.x, pair[1].0.z, pair[1].1, + if pair[0].1 == pair[1].1 { "DROPPED as interior or exterior" } else { "kept as boundary" } + ); + } + for region in ®ions { + println!( + "result ring: {:?}, area {}", + region + .outer + .iter() + .map(|point| (point.x, point.z)) + .collect::>(), + signed_area2(®ion.outer) + ); + } +} + +#[test] +#[ignore = "probe: prints which outer rings hold each hole and what the union returns in both operand orders"] +fn probe_defect_b_the_first_outer_ring_that_holds_a_hole_takes_it() { + let frame = [square(0.0, 0.0, 10.0, 10.0), hole(1.0, 1.0, 9.0, 9.0)]; + let island = [square(3.0, 3.0, 7.0, 7.0), hole(4.0, 4.0, 6.0, 6.0)]; + for (name, ring) in [ + ("frame hole 1..9", &frame[1]), + ("island hole 4..6", &island[1]), + ] { + println!( + "{name}: inside the frame outer {}, inside the island outer {}", + ring_inside2(ring, &frame[0], DEFAULT_EPS), + ring_inside2(ring, &island[0], DEFAULT_EPS) + ); + } + for (name, regions) in [ + ("union(frame, island)", union(&frame, &island)), + ("union(island, frame)", union(&island, &frame)), + ] { + println!( + "{name}: regions in output order {:?}", + regions + .iter() + .map(|region| ( + span(®ion.outer), + region + .holes + .iter() + .map(|ring| span(ring)) + .collect::>() + )) + .collect::>() + ); + } +} + +#[test] +#[ignore = "probe: prints the map of the union of two unit squares by gap"] +fn probe_map_of_the_union_of_two_squares_by_gap() { + let mut gap = 1.0e-9; + while gap < 2.0e-2 { + let regions = union( + &[square(0.0, 0.0, 1.0, 1.0)], + &[square(1.0 + gap, 0.0, 2.0 + gap, 1.0)], + ); + println!( + "gap {gap:.2e}: {} regions, points {:?}, area {}", + regions.len(), + regions + .iter() + .map(|region| region.outer.len()) + .collect::>(), + area(®ions) + ); + gap *= 3.0; + } +} From 039ae7eb5fd2458232949940a22b0c930ea0a0f1 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 21:56:13 +0200 Subject: [PATCH 28/29] test: the region boolean classes every edge and owns every hole rightly The tests of the fix, before the fix. No production code changes here. Eighteen fail on this commit and three pass. tests/region_boolean_probe.rs: the probes of step 0 are gone and its tests are live. All nine fail: two squares with a gap of 2e-6, 1e-5 or 9e-5 at three sizes and three distances; squares under a millimetre; a thin strip alone and in four positions against a square, in all three operations; no ring that is an open chain; a hole to the innermost region, in both operand orders and for three levels; an island 1e-5 inside a hole; an island that the operation clips. boolean2d.rs: five cases for the argument that a bounded probe is exact. Three pass already and must keep passing: coincident edges of the two operands, a zero-area sliver, a T junction. Two fail: a clearance below twice the tolerance, and the rule that a ring is degenerate when it is thinner than the tolerance (a ring 4e-6 wide is lost today, a square of side 3e-4 too). offset_regions.rs: the same through the function behind the export. Both fail: the nested hole, and two regions across a gap of 5e-5. analytic/booleans.rs: the two defects through boolean_brep. Both fail with an error today: a frame prism united with an island prism ("profile holes 0 and 1 overlap or nest", in one operand order only), and a profile hole 2e-5 from the edge of its host ("planar boolean produced a boundary without source ancestry"). region-boolean.test.mjs under test:wrapper: three cases through booleanRegions2D, all failing: the 5e-5 gap gives no region, the frame takes the island's hole, and a boundary that cannot be closed comes back as rings where it must be an error. That input uses an edge of 1 km whose split misses a vertex 0.5 mm from its end. --- .../tests/region-boolean.test.mjs | 64 +++ main/opengeometry/src/analytic/booleans.rs | 97 +++++ main/opengeometry/src/geometry/boolean2d.rs | 121 ++++++ .../src/geometry/offset_regions.rs | 89 ++++ .../tests/region_boolean_probe.rs | 398 ++++++++++-------- 5 files changed, 587 insertions(+), 182 deletions(-) create mode 100644 main/opengeometry-three/tests/region-boolean.test.mjs diff --git a/main/opengeometry-three/tests/region-boolean.test.mjs b/main/opengeometry-three/tests/region-boolean.test.mjs new file mode 100644 index 0000000..073a725 --- /dev/null +++ b/main/opengeometry-three/tests/region-boolean.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { booleanRegions2D, OpenGeometry } from "../../../dist/index.js"; + +const distFolder = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist"); + +before(async () => { + if (typeof globalThis.document === "undefined") { + globalThis.document = { + createElement: () => ({ style: {}, setAttribute() {}, appendChild() {} }), + }; + } + const wasmBytes = await readFile(path.join(distFolder, "opengeometry", "pkg", "opengeometry_bg.wasm")); + await OpenGeometry.create({ wasmURL: wasmBytes }); +}); + +function clockwise(x0, z0, x1, z1) { + return [[x0, z0], [x0, z1], [x1, z1], [x1, z0]].map(([x, z]) => ({ x, y: 0, z })); +} + +function region(x0, z0, x1, z1, hole) { + return { outer: clockwise(x0, z0, x1, z1), holes: hole ? [clockwise(...hole).reverse()] : [] }; +} + +function spanOf(ring) { + const along = ring.map((point) => point.x); + return `${Math.min(...along)}..${Math.max(...along)}`; +} + +function nestOf(regions) { + return regions + .map((item) => `${spanOf(item.outer)} [${item.holes.map(spanOf).sort().join(", ")}]`) + .sort(); +} + +test("two squares 0.05 mm apart unite to two regions", () => { + const united = booleanRegions2D([region(0, 0, 1, 1)], [region(1.00005, 0, 2.00005, 1)], "union"); + assert.equal(united.length, 2); + for (const item of united) { + assert.equal(item.outer.length, 4); + assert.equal(item.holes.length, 0); + } +}); + +test("an island in the hole of a frame keeps its own hole in both operand orders", () => { + const frame = region(0, 0, 10, 10, [1, 1, 9, 9]); + const island = region(3, 3, 7, 7, [4, 4, 6, 6]); + const expected = ["0..10 [1..9]", "3..7 [4..6]"]; + assert.deepEqual(nestOf(booleanRegions2D([frame], [island], "union")), expected); + assert.deepEqual(nestOf(booleanRegions2D([island], [frame], "union")), expected); + assert.deepEqual(nestOf(booleanRegions2D([frame, island], [], "union")), expected); +}); + +test("a boundary that cannot be closed is an error and not a ring", () => { + const long = region(0, 0, 1000, 1); + const besideItsEnd = region(999.9995, 1, 1000.5, 2); + assert.throws( + () => booleanRegions2D([long], [besideItsEnd], "union"), + (error) => String(error).includes("Boolean regions produced an open boundary"), + ); +}); diff --git a/main/opengeometry/src/analytic/booleans.rs b/main/opengeometry/src/analytic/booleans.rs index a57be1b..0013796 100644 --- a/main/opengeometry/src/analytic/booleans.rs +++ b/main/opengeometry/src/analytic/booleans.rs @@ -13463,4 +13463,101 @@ mod tests { super::super::query::PointClassification::Outside ); } + + fn region_prism( + id: &str, + outer: [f64; 4], + hole: Option<[f64; 4]>, + geometric: f64, + ) -> BrepEnvelope { + let ring = |b: [f64; 4]| vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]; + primitives::linear_extrusion( + id.into(), + Frame3::IDENTITY, + ring(outer), + hole.map(ring).into_iter().collect(), + 3.0, + Accuracy { + geometric, + intersection: geometric / 4.0, + tessellation: 0.01, + exchange: 1e-5, + }, + ) + .unwrap() + } + + fn material_at(brep: &BrepEnvelope, points: &[Point3]) -> Vec { + points + .iter() + .map(|point| { + super::super::query::classify_point(brep, *point).unwrap() + == super::super::query::PointClassification::Inside + }) + .collect() + } + + #[test] + fn union_of_a_frame_prism_and_an_island_prism_keeps_each_hole_in_both_orders() { + let frame = region_prism( + "frame", + [0.0, 0.0, 10.0, 10.0], + Some([1.0, 1.0, 9.0, 9.0]), + 1e-7, + ); + let island = region_prism( + "island", + [3.0, 3.0, 7.0, 7.0], + Some([4.0, 4.0, 6.0, 6.0]), + 1e-7, + ); + for (first, second) in [(&frame, &island), (&island, &frame)] { + let united = boolean_brep(first, second, BooleanOp::Union, "united".into()) + .unwrap() + .brep; + assert_eq!(united.solids.len(), 2); + assert_eq!(united.topology.faces.len(), 20); + assert_eq!( + material_at( + &united, + &[ + [0.5, 0.5, 1.5], + [2.0, 2.0, 1.5], + [3.5, 3.5, 1.5], + [5.0, 5.0, 1.5] + ] + ), + [true, false, true, false] + ); + } + } + + #[test] + fn a_profile_hole_inside_the_old_probe_reach_of_its_edge_survives_a_planar_boolean() { + let host = region_prism( + "host", + [0.0, 0.0, 1.0, 1.0], + Some([2.0e-5, 0.2, 0.5, 0.8]), + 1e-6, + ); + let cutter = region_prism("cutter", [0.6, -0.5, 0.7, 1.5], None, 1e-6); + let points = [ + [1.0e-5, 0.5, 1.5], + [0.25, 0.5, 1.5], + [0.65, 0.5, 1.5], + [0.9, 0.5, 1.5], + ]; + let cut = boolean_brep(&host, &cutter, BooleanOp::Subtraction, "cut".into()) + .unwrap() + .brep; + assert_eq!(cut.solids.len(), 2); + assert_eq!(cut.topology.faces.len(), 16); + assert_eq!(material_at(&cut, &points), [true, false, false, true]); + let united = boolean_brep(&host, &cutter, BooleanOp::Union, "united".into()) + .unwrap() + .brep; + assert_eq!(united.solids.len(), 1); + assert_eq!(united.topology.faces.len(), 18); + assert_eq!(material_at(&united, &points), [true, false, true, true]); + } } diff --git a/main/opengeometry/src/geometry/boolean2d.rs b/main/opengeometry/src/geometry/boolean2d.rs index fc702b9..d52efc0 100644 --- a/main/opengeometry/src/geometry/boolean2d.rs +++ b/main/opengeometry/src/geometry/boolean2d.rs @@ -666,4 +666,125 @@ mod tests { assert_eq!(result.len(), 1); assert!((signed_area2(&result[0].outer) - 1.5 * scale * scale).abs() < 1.0e-14); } + + fn total_area(regions: &[RingRegion]) -> f64 { + regions + .iter() + .map(|region| { + signed_area2(®ion.outer).abs() + - region + .holes + .iter() + .map(|ring| signed_area2(ring).abs()) + .sum::() + }) + .sum() + } + + fn areas_of_all_operations(a: &[Vec], b: &[Vec]) -> [(usize, f64); 3] { + [ + PlanarBooleanOp::Union, + PlanarBooleanOp::Intersection, + PlanarBooleanOp::Subtraction, + ] + .map(|operation| { + let regions = boolean_oriented_regions(a, b, operation, DEFAULT_EPS); + (regions.len(), total_area(®ions)) + }) + } + + fn assert_counts_and_areas(got: [(usize, f64); 3], expected: [(usize, f64); 3]) { + for (index, (got, expected)) in got.into_iter().zip(expected).enumerate() { + assert_eq!(got.0, expected.0, "operation {index}"); + assert!( + (got.1 - expected.1).abs() < 1.0e-9, + "operation {index}: area {}, expected {}", + got.1, + expected.1 + ); + } + } + + #[test] + fn coincident_edges_of_two_operands_class_by_both_windings() { + let unit = vec![ccw_square(0.0, 0.0, 1.0, 1.0)]; + let beside = vec![ccw_square(1.0, 0.0, 2.0, 1.0)]; + assert_counts_and_areas( + areas_of_all_operations(&unit, &beside), + [(1, 2.0), (0, 0.0), (1, 1.0)], + ); + let within = vec![ccw_square(0.0, 0.0, 0.5, 1.0)]; + assert_counts_and_areas( + areas_of_all_operations(&unit, &within), + [(1, 1.0), (1, 0.5), (1, 0.5)], + ); + assert_counts_and_areas( + areas_of_all_operations(&unit, &unit), + [(1, 1.0), (1, 1.0), (0, 0.0)], + ); + } + + #[test] + fn a_zero_area_sliver_adds_nothing_inside_or_outside_a_region() { + let unit = vec![ccw_square(0.0, 0.0, 1.0, 1.0)]; + for sliver in [ + vec![p(0.2, 0.5), p(0.8, 0.5), p(0.5, 0.5)], + vec![p(1.5, 0.5), p(2.5, 0.5), p(2.0, 0.5)], + ] { + let regions = + boolean_oriented_regions(&unit, &[sliver], PlanarBooleanOp::Union, DEFAULT_EPS); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].outer.len(), 4); + assert!(regions[0].holes.is_empty()); + assert!((total_area(®ions) - 1.0).abs() < 1.0e-12); + } + } + + #[test] + fn a_t_junction_splits_the_edge_it_meets() { + let unit = vec![ccw_square(0.0, 0.0, 1.0, 1.0)]; + let stem = vec![ccw_square(0.25, 1.0, 0.75, 2.0)]; + let regions = boolean_oriented_regions(&unit, &stem, PlanarBooleanOp::Union, DEFAULT_EPS); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].outer.len(), 8); + assert!((total_area(®ions) - 1.5).abs() < 1.0e-12); + } + + #[test] + fn a_clearance_below_twice_the_tolerance_still_classes_both_edges() { + let gap = 1.5 * DEFAULT_EPS; + let unit = vec![ccw_square(0.0, 0.0, 1.0, 1.0)]; + let beside = vec![ccw_square(1.0 + gap, 0.0, 2.0 + gap, 1.0)]; + assert_counts_and_areas( + areas_of_all_operations(&unit, &beside), + [(2, 2.0), (0, 0.0), (1, 1.0)], + ); + } + + #[test] + fn a_ring_is_degenerate_when_it_is_thinner_than_the_tolerance() { + let long = |width: f64| vec![ccw_square(0.0, 0.0, 10.0, width)]; + let thinner = boolean_oriented_regions( + &long(0.4 * DEFAULT_EPS), + &[], + PlanarBooleanOp::Union, + DEFAULT_EPS, + ); + assert!(thinner.is_empty()); + let wider = boolean_oriented_regions( + &long(4.0 * DEFAULT_EPS), + &[], + PlanarBooleanOp::Union, + DEFAULT_EPS, + ); + assert_eq!(wider.len(), 1); + assert!((total_area(&wider) - 40.0 * DEFAULT_EPS).abs() < 1.0e-12); + let small = boolean_oriented_regions( + &[ccw_square(0.0, 0.0, 3.0e-4, 3.0e-4)], + &[], + PlanarBooleanOp::Union, + DEFAULT_EPS, + ); + assert_eq!(small.len(), 1); + } } diff --git a/main/opengeometry/src/geometry/offset_regions.rs b/main/opengeometry/src/geometry/offset_regions.rs index 82ea72a..1cdbdfe 100644 --- a/main/opengeometry/src/geometry/offset_regions.rs +++ b/main/opengeometry/src/geometry/offset_regions.rs @@ -144,6 +144,95 @@ mod planar_boolean_export_tests { .collect() } + fn square_region(low: f64, high: f64, hole: Option<(f64, f64)>) -> OffsetRegion { + let clockwise = + |low: f64, high: f64| ring(&[(low, low), (low, high), (high, high), (high, low)]); + let holes = hole + .map(|(low, high)| { + let mut counter_clockwise = clockwise(low, high); + counter_clockwise.reverse(); + counter_clockwise + }) + .into_iter() + .collect(); + (clockwise(low, high), holes) + } + + fn spans(regions: &[OffsetRegion]) -> Vec<((i64, i64), Vec<(i64, i64)>)> { + let span = |points: &Vec| { + let low = points + .iter() + .map(|point| point.x) + .fold(f64::INFINITY, f64::min); + let high = points + .iter() + .map(|point| point.x) + .fold(f64::NEG_INFINITY, f64::max); + ( + (low * 1000.0).round() as i64, + (high * 1000.0).round() as i64, + ) + }; + let mut all: Vec<_> = regions + .iter() + .map(|(outer, holes)| { + let mut inner: Vec<_> = holes.iter().map(span).collect(); + inner.sort_unstable(); + (span(outer), inner) + }) + .collect(); + all.sort(); + all + } + + #[test] + fn exported_boolean_gives_a_nested_hole_to_its_own_region_in_both_orders() { + let frame = square_region(0.0, 10.0, Some((1.0, 9.0))); + let island = square_region(3.0, 7.0, Some((4.0, 6.0))); + let expected = spans(&[frame.clone(), island.clone()]); + for (a, b) in [ + (vec![frame.clone()], vec![island.clone()]), + (vec![island.clone()], vec![frame.clone()]), + (vec![frame.clone(), island.clone()], Vec::new()), + ] { + let united = boolean_offset_regions(&a, &b, PlanarBooleanOp::Union).unwrap(); + assert_eq!(spans(&united), expected); + } + let levels = [ + square_region(0.0, 20.0, Some((1.0, 19.0))), + square_region(3.0, 17.0, Some((4.0, 16.0))), + square_region(6.0, 14.0, Some((7.0, 13.0))), + square_region(9.0, 11.0, None), + ]; + let united = + boolean_offset_regions(&levels[..1], &levels[1..], PlanarBooleanOp::Union).unwrap(); + assert_eq!(spans(&united), spans(&levels)); + } + + #[test] + fn exported_boolean_keeps_two_regions_across_a_gap_inside_the_old_probe_reach() { + let left = vec![( + ring(&[(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)]), + Vec::new(), + )]; + let right = vec![( + ring(&[ + (1.00005, 0.0), + (1.00005, 1.0), + (2.00005, 1.0), + (2.00005, 0.0), + ]), + Vec::new(), + )]; + let united = boolean_offset_regions(&left, &right, PlanarBooleanOp::Union).unwrap(); + assert_eq!(united.len(), 2); + for (outer, holes) in &united { + assert_eq!(outer.len(), 4); + assert!(holes.is_empty()); + assert!((signed_area_xz(outer) + 1.0).abs() < 1.0e-9); + } + } + #[test] fn exported_boolean_preserves_split_regions_and_winding() { let a = vec![( diff --git a/main/opengeometry/tests/region_boolean_probe.rs b/main/opengeometry/tests/region_boolean_probe.rs index 0d54d96..72f1d22 100644 --- a/main/opengeometry/tests/region_boolean_probe.rs +++ b/main/opengeometry/tests/region_boolean_probe.rs @@ -1,11 +1,9 @@ -use opengeometry::geometry::boolean2d::{ - boolean_oriented_regions, regions_from_edges_by, PlanarBooleanOp, RingRegion, -}; -use opengeometry::geometry::poly2d::{ - ring_inside2, signed_area2, winding_number2, Edge2, Pt2, DEFAULT_EPS, -}; +use opengeometry::geometry::boolean2d::{boolean_oriented_regions, PlanarBooleanOp, RingRegion}; +use opengeometry::geometry::poly2d::{signed_area2, Pt2, DEFAULT_EPS}; -fn square(x0: f64, z0: f64, x1: f64, z1: f64) -> Vec { +const GAPS_INSIDE_THE_OLD_REACH: [f64; 3] = [2.0e-6, 1.0e-5, 9.0e-5]; + +fn rectangle(x0: f64, z0: f64, x1: f64, z1: f64) -> Vec { vec![ Pt2::new(x0, z0), Pt2::new(x1, z0), @@ -15,23 +13,37 @@ fn square(x0: f64, z0: f64, x1: f64, z1: f64) -> Vec { } fn hole(x0: f64, z0: f64, x1: f64, z1: f64) -> Vec { - let mut ring = square(x0, z0, x1, z1); + let mut ring = rectangle(x0, z0, x1, z1); ring.reverse(); ring } +fn framed(low: f64, high: f64) -> Vec> { + vec![ + rectangle(low, low, high, high), + hole(low + 1.0, low + 1.0, high - 1.0, high - 1.0), + ] +} + +fn regions(a: &[Vec], b: &[Vec], operation: PlanarBooleanOp) -> Vec { + boolean_oriented_regions(a, b, operation, DEFAULT_EPS) +} + +fn union(a: &[Vec], b: &[Vec]) -> Vec { + regions(a, b, PlanarBooleanOp::Union) +} + +fn region_area(region: &RingRegion) -> f64 { + signed_area2(®ion.outer).abs() + - region + .holes + .iter() + .map(|ring| signed_area2(ring).abs()) + .sum::() +} + fn area(regions: &[RingRegion]) -> f64 { - regions - .iter() - .map(|region| { - signed_area2(®ion.outer).abs() - - region - .holes - .iter() - .map(|ring| signed_area2(ring).abs()) - .sum::() - }) - .sum() + regions.iter().map(region_area).sum() } fn span(ring: &[Pt2]) -> (f64, f64) { @@ -46,219 +58,241 @@ fn span(ring: &[Pt2]) -> (f64, f64) { (low, high) } -fn hole_spans(regions: &[RingRegion]) -> Vec<((f64, f64), Vec<(f64, f64)>)> { - let mut spans: Vec<_> = regions +type Nest = Vec<((f64, f64), Vec<(f64, f64)>)>; + +fn nest(regions: &[RingRegion]) -> Nest { + let mut spans: Nest = regions .iter() .map(|region| { - ( - span(®ion.outer), - region.holes.iter().map(|ring| span(ring)).collect(), - ) + let mut holes: Vec<_> = region.holes.iter().map(|ring| span(ring)).collect(); + holes.sort_by(|first, second| first.0.total_cmp(&second.0)); + (span(®ion.outer), holes) }) .collect(); spans.sort_by(|first, second| first.0 .0.total_cmp(&second.0 .0)); spans } -fn union(a: &[Vec], b: &[Vec]) -> Vec { - boolean_oriented_regions(a, b, PlanarBooleanOp::Union, DEFAULT_EPS) +fn assert_area(regions: &[RingRegion], expected: f64, case: &str) { + let got = area(regions); + assert!( + (got - expected).abs() <= 1.0e-9 * expected.max(1.0e-6), + "{case}: area {got}, expected {expected}" + ); } #[test] -#[ignore = "defect A: fails until the side of an edge no longer comes from a probe at a fixed reach"] -fn union_keeps_two_squares_whose_gap_is_above_the_tolerance_and_below_the_probe_reach() { - for gap in [2.0e-6, 1.0e-5, 9.0e-5] { - let regions = union( - &[square(0.0, 0.0, 1.0, 1.0)], - &[square(1.0 + gap, 0.0, 2.0 + gap, 1.0)], - ); - assert_eq!(regions.len(), 2, "gap {gap}"); - assert!((area(®ions) - 2.0).abs() < 1.0e-9, "gap {gap}"); +fn two_squares_with_a_small_gap_stay_two_regions_at_every_size_and_distance() { + for side in [1.0e-2, 1.0, 1.0e3] { + for far in [0.0, 100.0, 1000.0] { + for gap in GAPS_INSIDE_THE_OLD_REACH { + let case = format!("side {side}, at {far}, gap {gap}"); + let a = [rectangle(far, far, far + side, far + side)]; + let b = [rectangle( + far + side + gap, + far, + far + 2.0 * side + gap, + far + side, + )]; + let united = union(&a, &b); + assert_eq!(united.len(), 2, "{case}"); + assert!( + (area(&united) / (2.0 * side * side) - 1.0).abs() < 1.0e-6, + "{case}: area {}", + area(&united) + ); + assert!( + regions(&a, &b, PlanarBooleanOp::Intersection).is_empty(), + "{case}" + ); + let kept = regions(&a, &b, PlanarBooleanOp::Subtraction); + assert_eq!(kept.len(), 1, "{case}"); + assert_eq!(kept[0].outer.len(), 4, "{case}"); + } + } } } #[test] -#[ignore = "defect A: fails until the side of an edge no longer comes from a probe at a fixed reach"] -fn union_keeps_a_strip_that_is_thinner_than_the_probe_reach() { +fn squares_smaller_than_a_millimetre_are_kept_at_every_gap() { + for side in [3.0e-4, 1.0e-4] { + for gap in [2.0e-6, 1.0e-5, 9.0e-5, 1.0e-3, 1.0e-2] { + let united = union( + &[rectangle(0.0, 0.0, side, side)], + &[rectangle(side + gap, 0.0, 2.0 * side + gap, side)], + ); + assert_eq!(united.len(), 2, "side {side}, gap {gap}"); + assert!( + (area(&united) / (2.0 * side * side) - 1.0).abs() < 1.0e-6, + "side {side}, gap {gap}: area {}", + area(&united) + ); + } + } +} + +#[test] +fn a_strip_thinner_than_the_old_reach_is_a_region_of_its_own() { for width in [3.0e-6, 1.0e-5, 9.0e-5] { - let alone = union(&[square(0.0, 0.0, 1.0, width)], &[]); + let strip = [rectangle(0.0, 0.0, 1.0, width)]; + let alone = union(&strip, &[]); assert_eq!(alone.len(), 1, "width {width}"); - assert!((area(&alone) - width).abs() < 1.0e-12, "width {width}"); + assert_area(&alone, width, "strip alone"); + let kept = regions(&strip, &[], PlanarBooleanOp::Subtraction); + assert_eq!(kept.len(), 1, "width {width}"); + assert_area(&kept, width, "strip minus nothing"); } } #[test] -#[ignore = "defect A: fails until the side of an edge no longer comes from a probe at a fixed reach"] -fn a_thin_strip_across_a_square_does_not_erase_the_square() { - for width in [3.0e-6, 1.0e-5, 9.0e-5] { - let unit = [square(0.0, 0.0, 1.0, 1.0)]; - let strip = [square(-0.5, 0.5, 1.5, 0.5 + width)]; - let united = union(&unit, &strip); - assert_eq!(united.len(), 1, "width {width}"); - assert!( - (area(&united) - (1.0 + width)).abs() < 1.0e-9, - "width {width}" - ); - let cut = - boolean_oriented_regions(&unit, &strip, PlanarBooleanOp::Subtraction, DEFAULT_EPS); - assert_eq!(cut.len(), 2, "width {width}"); - assert!((area(&cut) - (1.0 - width)).abs() < 1.0e-9, "width {width}"); +fn a_thin_strip_and_a_square_combine_rightly_in_every_position() { + let width = 1.0e-5; + let unit = [rectangle(0.0, 0.0, 1.0, 1.0)]; + let on_the_top_edge = [rectangle(0.0, 1.0, 1.0, 1.0 + width)]; + let across = [rectangle(-0.5, 0.5, 1.5, 0.5 + width)]; + let above = [rectangle(0.0, 1.5, 1.0, 1.5 + width)]; + let inside = [rectangle(0.2, 0.5, 0.8, 0.5 + width)]; + type Expected = (usize, f64); + let cases: [(&str, &[Vec], Expected, Expected, Expected); 4] = [ + ( + "on the top edge", + &on_the_top_edge, + (1, 1.0 + width), + (0, 0.0), + (1, 1.0), + ), + ( + "across", + &across, + (1, 1.0 + width), + (1, width), + (2, 1.0 - width), + ), + ("above", &above, (2, 1.0 + width), (0, 0.0), (1, 1.0)), + ( + "inside", + &inside, + (1, 1.0), + (1, 0.6 * width), + (1, 1.0 - 0.6 * width), + ), + ]; + for (name, strip, united, common, cut) in cases { + for (operation, (count, expected)) in [ + (PlanarBooleanOp::Union, united), + (PlanarBooleanOp::Intersection, common), + (PlanarBooleanOp::Subtraction, cut), + ] { + let case = format!("strip {name}, {operation:?}"); + let result = regions(&unit, strip, operation); + assert_eq!(result.len(), count, "{case}"); + if count > 0 { + assert_area(&result, expected, &case); + } + } } + let slot = regions(&unit, &inside, PlanarBooleanOp::Subtraction); + assert_eq!(slot[0].holes.len(), 1); } #[test] -#[ignore = "defect A: fails until an open chain of boundary edges is no longer returned as a ring"] -fn a_result_is_never_an_open_chain_closed_by_a_straight_line() { - let regions = union( - &[square(0.0, 0.0, 1.0, 1.0)], - &[square(1.0 + 1.0e-5, 0.0, 2.0 + 1.0e-5, 1.0)], - ); - for region in ®ions { - assert!( - region.outer.len() >= 4, - "a ring of {} points with area {}", - region.outer.len(), - signed_area2(®ion.outer) +fn no_result_ring_is_an_open_chain_closed_by_a_straight_line() { + for gap in GAPS_INSIDE_THE_OLD_REACH { + let united = union( + &[rectangle(0.0, 0.0, 1.0, 1.0)], + &[rectangle(1.0 + gap, 0.0, 2.0 + gap, 1.0)], ); + for region in &united { + assert_eq!(region.outer.len(), 4, "gap {gap}"); + assert!( + (signed_area2(®ion.outer) - 1.0).abs() < 1.0e-9, + "gap {gap}" + ); + } } } #[test] -#[ignore = "defect B: fails until a hole goes to the innermost region that holds it"] -fn union_gives_each_hole_to_the_innermost_region_that_holds_it() { - let frame = [square(0.0, 0.0, 10.0, 10.0), hole(1.0, 1.0, 9.0, 9.0)]; - let island = [square(3.0, 3.0, 7.0, 7.0), hole(4.0, 4.0, 6.0, 6.0)]; - let expected = vec![ +fn a_hole_goes_to_the_innermost_region_that_holds_it_in_both_operand_orders() { + let frame = framed(0.0, 10.0); + let island = framed(3.0, 7.0); + let expected: Nest = vec![ ((0.0, 10.0), vec![(1.0, 9.0)]), ((3.0, 7.0), vec![(4.0, 6.0)]), ]; - assert_eq!(hole_spans(&union(&frame, &island)), expected); - assert_eq!(hole_spans(&union(&island, &frame)), expected); + assert_eq!(nest(&union(&frame, &island)), expected); + assert_eq!(nest(&union(&island, &frame)), expected); + let both: Vec> = frame.iter().chain(&island).cloned().collect(); + assert_eq!(nest(&union(&both, &[])), expected); + for region in union(&frame, &island) { + assert!(region_area(®ion) > 0.0); + } } #[test] -#[ignore = "defect B: fails until a hole goes to the innermost region that holds it"] fn three_nested_rings_keep_their_own_holes_in_every_operation() { - let level = |low: f64, high: f64| { - vec![ - square(low, low, high, high), - hole(low + 1.0, low + 1.0, high - 1.0, high - 1.0), - ] - }; - let outer = level(0.0, 20.0); - let inner: Vec> = level(3.0, 17.0) - .into_iter() - .chain(level(6.0, 14.0)) - .chain(std::iter::once(square(9.0, 9.0, 11.0, 11.0))) - .collect(); - let expected = vec![ + let outer = framed(0.0, 20.0); + let middle = framed(3.0, 17.0); + let inner = framed(6.0, 14.0); + let core = rectangle(9.0, 9.0, 11.0, 11.0); + let expected: Nest = vec![ ((0.0, 20.0), vec![(1.0, 19.0)]), ((3.0, 17.0), vec![(4.0, 16.0)]), ((6.0, 14.0), vec![(7.0, 13.0)]), ((9.0, 11.0), vec![]), ]; - assert_eq!(hole_spans(&union(&outer, &inner)), expected); - let all: Vec> = outer.iter().chain(&inner).cloned().collect(); - let cover = [square(0.0, 0.0, 20.0, 20.0)]; - let kept = boolean_oriented_regions(&all, &cover, PlanarBooleanOp::Intersection, DEFAULT_EPS); - assert_eq!(hole_spans(&kept), expected); + let inside: Vec> = middle + .iter() + .chain(&inner) + .cloned() + .chain(std::iter::once(core.clone())) + .collect(); + assert_eq!(nest(&union(&outer, &inside)), expected); + assert_eq!(nest(&union(&inside, &outer)), expected); + let all: Vec> = outer.iter().chain(&inside).cloned().collect(); + let cover = [rectangle(0.0, 0.0, 20.0, 20.0)]; + let kept = regions(&all, &cover, PlanarBooleanOp::Intersection); + assert_eq!(nest(&kept), expected); for region in &kept { - assert!(area(std::slice::from_ref(region)) > 0.0); + assert!(region_area(region) > 0.0); } + let cut = regions(&cover, &middle, PlanarBooleanOp::Subtraction); + let expected_cut: Nest = vec![((0.0, 20.0), vec![(3.0, 17.0)]), ((4.0, 16.0), vec![])]; + assert_eq!(nest(&cut), expected_cut); } #[test] -#[ignore = "probe: prints how each edge of two squares 1e-5 apart is classed"] -fn probe_defect_a_the_two_facing_edges_are_classed_as_interior() { +fn an_island_a_hundredth_of_a_millimetre_inside_a_hole_stays_a_region() { let gap = 1.0e-5; - let a = square(0.0, 0.0, 1.0, 1.0); - let b = square(1.0 + gap, 0.0, 2.0 + gap, 1.0); - let edges: Vec = [&a, &b] - .into_iter() - .flat_map(|ring| (0..4).map(move |index| (ring[index], ring[(index + 1) % 4]))) - .collect(); - let calls = std::cell::RefCell::new(Vec::new()); - let regions = regions_from_edges_by(&edges, DEFAULT_EPS, |point| { - let filled = winding_number2(point, &a) != 0 || winding_number2(point, &b) != 0; - calls.borrow_mut().push((point, filled)); - filled - }); - for (edge, pair) in edges.iter().zip(calls.borrow().chunks(2)) { - println!( - "edge ({}, {}) -> ({}, {}): left probe ({:.6}, {:.6}) filled {}, right probe ({:.6}, {:.6}) filled {} => {}", - edge.0.x, edge.0.z, edge.1.x, edge.1.z, - pair[0].0.x, pair[0].0.z, pair[0].1, - pair[1].0.x, pair[1].0.z, pair[1].1, - if pair[0].1 == pair[1].1 { "DROPPED as interior or exterior" } else { "kept as boundary" } - ); - } - for region in ®ions { - println!( - "result ring: {:?}, area {}", - region - .outer - .iter() - .map(|point| (point.x, point.z)) - .collect::>(), - signed_area2(®ion.outer) - ); - } -} - -#[test] -#[ignore = "probe: prints which outer rings hold each hole and what the union returns in both operand orders"] -fn probe_defect_b_the_first_outer_ring_that_holds_a_hole_takes_it() { - let frame = [square(0.0, 0.0, 10.0, 10.0), hole(1.0, 1.0, 9.0, 9.0)]; - let island = [square(3.0, 3.0, 7.0, 7.0), hole(4.0, 4.0, 6.0, 6.0)]; - for (name, ring) in [ - ("frame hole 1..9", &frame[1]), - ("island hole 4..6", &island[1]), - ] { - println!( - "{name}: inside the frame outer {}, inside the island outer {}", - ring_inside2(ring, &frame[0], DEFAULT_EPS), - ring_inside2(ring, &island[0], DEFAULT_EPS) - ); - } - for (name, regions) in [ - ("union(frame, island)", union(&frame, &island)), - ("union(island, frame)", union(&island, &frame)), - ] { - println!( - "{name}: regions in output order {:?}", - regions - .iter() - .map(|region| ( - span(®ion.outer), - region - .holes - .iter() - .map(|ring| span(ring)) - .collect::>() - )) - .collect::>() - ); + let frame = framed(0.0, 10.0); + let island = vec![ + rectangle(1.0 + gap, 1.0 + gap, 9.0 - gap, 9.0 - gap), + hole(4.0, 4.0, 6.0, 6.0), + ]; + let expected = 36.0 + (8.0 - 2.0 * gap) * (8.0 - 2.0 * gap) - 4.0; + for united in [union(&frame, &island), union(&island, &frame)] { + assert_eq!(united.len(), 2); + assert_area(&united, expected, "island near the hole boundary"); + assert!(united.iter().all(|region| region.holes.len() == 1)); } } #[test] -#[ignore = "probe: prints the map of the union of two unit squares by gap"] -fn probe_map_of_the_union_of_two_squares_by_gap() { - let mut gap = 1.0e-9; - while gap < 2.0e-2 { - let regions = union( - &[square(0.0, 0.0, 1.0, 1.0)], - &[square(1.0 + gap, 0.0, 2.0 + gap, 1.0)], - ); - println!( - "gap {gap:.2e}: {} regions, points {:?}, area {}", - regions.len(), - regions - .iter() - .map(|region| region.outer.len()) - .collect::>(), - area(®ions) - ); - gap *= 3.0; - } +fn an_island_that_the_operation_clips_keeps_its_hole() { + let frame = framed(0.0, 10.0); + let island = framed(3.0, 7.0); + let nested: Vec> = frame.iter().chain(&island).cloned().collect(); + let window = [rectangle(-1.0, -1.0, 6.5, 11.0)]; + let clipped = regions(&nested, &window, PlanarBooleanOp::Intersection); + let expected: Nest = vec![((0.0, 6.5), vec![]), ((3.0, 6.5), vec![(4.0, 6.0)])]; + assert_eq!(nest(&clipped), expected); + assert_area(&clipped, 21.0 + 10.0, "the clipped nest"); + let notch = [rectangle(6.5, 6.5, 7.5, 7.5)]; + let notched = regions(&nested, ¬ch, PlanarBooleanOp::Subtraction); + let expected_notched: Nest = vec![ + ((0.0, 10.0), vec![(1.0, 9.0)]), + ((3.0, 7.0), vec![(4.0, 6.0)]), + ]; + assert_eq!(nest(¬ched), expected_notched); + assert_area(¬ched, 36.0 + 12.0 - 0.25, "the notched island"); } From 4764543eb6762f0197389cbb7cebce050e45a371 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 10 Oct 2026 22:04:21 +0200 Subject: [PATCH 29/29] fix(boolean2d): class each edge by a probe that cannot cross a neighbour The 2D region boolean decided the side of a sub-edge with two probe points at min(len/4, 1e-4) from its midpoint. A boundary nearer than that made both probes agree, the edge was dropped, and the open chain that was left came back as a ring. Two squares with a gap between 1e-6 and 1e-4 united to a triangle; a strip thinner than 1e-4 was lost or took its neighbour with it. Four rules change. The reach of the probe is now min(len/4, 1e-4, clearance/2), where the clearance is the least distance from the midpoint to any other sub-edge that is farther than eps. A probe at that reach is at least half the clearance away from every other such sub-edge, so it sees the fill beside this edge alone. The sub-edges within eps of the midpoint are the coincident ones, which the probes class by the windings of both operands, as before. The probe of the self-crossing offset loops (cap 1e-3) uses the same rule. An open chain is an error, RegionBooleanError::OpenBoundary, and never a ring. The 2D functions return a Result. The analytic callers map it to the InvalidTopology text they raise for the same fact; the exports give "Boolean regions produced an open boundary". At the two calls with a material predicate the predicate's own failure is still reported first. A ring is degenerate when its area is below eps times half its perimeter, that is, when it is thinner than eps. The old test compared an area with a length, so every ring under 1e-6 m2 vanished (squares of side 3e-4), and a ring 10 m long and 4e-7 wide survived. Changed at the ring classification, at the loops of a self-crossing ring and at the result filter of offset_ring_variable. A hole goes to the smallest outer ring that contains it, the rule of the curved boolean. A hole that no region contains is an error, OrphanHole, not a silent drop. Before, the first outer ring in output order took it, so a frame took the hole of an island inside it. offset_polyline_regions and offset_polyline_group_regions returned a Vec and now return a Result, so that no caller can drop the error; their existing tests unwrap. No existing test changes its result. --- main/opengeometry/src/analytic/booleans.rs | 14 +- .../src/analytic/planar_booleans.rs | 29 ++- main/opengeometry/src/geometry/boolean2d.rs | 198 ++++++++++++++---- .../src/geometry/offset_regions.rs | 132 ++++++------ .../tests/region_boolean_probe.rs | 2 +- 5 files changed, 265 insertions(+), 110 deletions(-) diff --git a/main/opengeometry/src/analytic/booleans.rs b/main/opengeometry/src/analytic/booleans.rs index 0013796..5cec66f 100644 --- a/main/opengeometry/src/analytic/booleans.rs +++ b/main/opengeometry/src/analytic/booleans.rs @@ -1683,7 +1683,7 @@ fn boolean_planar_extrusions( &b_contours, planar_operation, accuracy.intersection, - ); + )?; let mut out = BrepEnvelope::new(id, accuracy)?; for (region_index, region) in regions.iter().enumerate() { let (outer, holes) = region_profiles(region); @@ -13560,4 +13560,16 @@ mod tests { assert_eq!(united.topology.faces.len(), 18); assert_eq!(material_at(&united, &points), [true, false, true, true]); } + + #[test] + fn a_region_boundary_that_cannot_be_closed_is_an_invalid_topology_error() { + let long = region_prism("long", [0.0, 0.0, 1000.0, 1.0], None, 1e-5); + let beside_its_end = region_prism("beside", [999.9995, 1.0, 1000.5, 2.0], None, 1e-5); + let result = boolean_brep(&long, &beside_its_end, BooleanOp::Union, "united".into()); + assert!( + matches!(&result, Err(GeometryError::InvalidTopology(message)) if message == "layered planar Boolean has an open boundary"), + "{:?}", + result.map(|result| result.brep.topology.faces.len()) + ); + } } diff --git a/main/opengeometry/src/analytic/planar_booleans.rs b/main/opengeometry/src/analytic/planar_booleans.rs index d93ecbe..df14481 100644 --- a/main/opengeometry/src/analytic/planar_booleans.rs +++ b/main/opengeometry/src/analytic/planar_booleans.rs @@ -19,7 +19,10 @@ use super::{ }; use crate::{ geometry::{ - boolean2d::{boolean_oriented_regions, regions_from_edges_by, PlanarBooleanOp, RingRegion}, + boolean2d::{ + boolean_oriented_regions, regions_from_edges_by, PlanarBooleanOp, RegionBooleanError, + RingRegion, + }, poly2d::{winding_number2, Pt2}, }, math::interval::Interval, @@ -100,6 +103,17 @@ fn loop_positions(brep: &BrepEnvelope, loop_id: u32) -> Result, Geom Ok(points) } +impl From for GeometryError { + fn from(error: RegionBooleanError) -> Self { + Self::InvalidTopology(match error { + RegionBooleanError::OpenBoundary => { + "layered planar Boolean has an open boundary".into() + } + RegionBooleanError::OrphanHole => error.to_string(), + }) + } +} + fn slice_regions( brep: &BrepEnvelope, frame: Frame3, @@ -187,7 +201,7 @@ fn slice_regions( &[], PlanarBooleanOp::Union, tolerance, - )) + )?) } fn side_sources( @@ -885,7 +899,7 @@ pub(super) fn subtract_layered_extrusions( }, PlanarBooleanOp::Subtraction, accuracy.intersection, - )) + )?) }) .collect::, _>>()?; let mut exposed_up = Vec::new(); @@ -898,13 +912,13 @@ pub(super) fn subtract_layered_extrusions( &above, PlanarBooleanOp::Subtraction, accuracy.intersection, - )); + )?); exposed_down.push(boolean_oriented_regions( &above, &below, PlanarBooleanOp::Subtraction, accuracy.intersection, - )); + )?); } let mut vertices = Vec::new(); for layer in &layers { @@ -1359,7 +1373,7 @@ pub(super) fn subtract_planar_polyhedra( &cut, PlanarBooleanOp::Subtraction, accuracy.intersection, - ) + )? } else { let mut edges = planar_section_segments(cutter, &cutter_faces, face.frame, accuracy.intersection)?; @@ -1427,7 +1441,7 @@ pub(super) fn subtract_planar_polyhedra( if let Some(error) = failure.into_inner() { return Err(error); } - regions + regions? }; for region in regions { pieces.push(PlanarFacet { @@ -1512,6 +1526,7 @@ pub(super) fn subtract_planar_polyhedra( if let Some(error) = failure.into_inner() { return Err(error); } + let regions = regions?; let reversed = Frame3 { origin: face.frame.origin, x: face.frame.x, diff --git a/main/opengeometry/src/geometry/boolean2d.rs b/main/opengeometry/src/geometry/boolean2d.rs index d52efc0..915be18 100644 --- a/main/opengeometry/src/geometry/boolean2d.rs +++ b/main/opengeometry/src/geometry/boolean2d.rs @@ -2,6 +2,62 @@ use crate::geometry::poly2d::*; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RegionBooleanError { + OpenBoundary, + OrphanHole, +} + +pub type RegionResult = Result, RegionBooleanError>; + +impl std::fmt::Display for RegionBooleanError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::OpenBoundary => "Boolean regions produced an open boundary", + Self::OrphanHole => "Boolean regions produced a hole outside every region", + }) + } +} + +fn distance_to_segment(point: Pt2, a: Pt2, b: Pt2) -> f64 { + let along = Pt2::new(b.x - a.x, b.z - a.z); + let length_squared = dot2(along, along); + let t = if length_squared > 0.0 { + (dot2(Pt2::new(point.x - a.x, point.z - a.z), along) / length_squared).clamp(0.0, 1.0) + } else { + 0.0 + }; + (a.x + t * along.x - point.x).hypot(a.z + t * along.z - point.z) +} + +fn probe_reach(mid: Pt2, len: f64, most: f64, sub_edges: &[Edge2], eps: f64) -> f64 { + let mut clearance = 2.0 * (len * 0.25).min(most); + for &(a, b) in sub_edges { + if a.x.min(b.x) - mid.x >= clearance + || mid.x - a.x.max(b.x) >= clearance + || a.z.min(b.z) - mid.z >= clearance + || mid.z - a.z.max(b.z) >= clearance + { + continue; + } + let distance = distance_to_segment(mid, a, b); + if distance > eps && distance < clearance { + clearance = distance; + } + } + clearance * 0.5 +} + +pub fn ring_is_degenerate(ring: &[Pt2], eps: f64) -> bool { + let perimeter: f64 = (0..ring.len()) + .map(|index| { + let next = ring[(index + 1) % ring.len()]; + (ring[index].x - next.x).hypot(ring[index].z - next.z) + }) + .sum(); + ring.len() < 3 || signed_area2(ring).abs() < eps * perimeter * 0.5 +} + /// Decompose a (possibly self-intersecting) closed ring into simple loops by /// splitting at every self-crossing (stack method). Port of `decomposeSelfIntersections`. /// The `HashMap` here is keyed by integer node id and only ever LOOKED UP (never @@ -87,7 +143,7 @@ pub fn decompose_self_intersections(ring: &[Pt2], eps: f64) -> Vec> { /// A point just off the midpoint of `loop`'s first usable edge, on the given side. /// Port of `edgeProbe2`. -pub fn edge_probe2(ring: &[Pt2], side: f64) -> Option { +pub fn edge_probe2(ring: &[Pt2], side: f64, sub_edges: &[Edge2], eps: f64) -> Option { for i in 0..ring.len() { let a = ring[i]; let b = ring[(i + 1) % ring.len()]; @@ -99,9 +155,9 @@ pub fn edge_probe2(ring: &[Pt2], side: f64) -> Option { } let nx = -ez / len; let nz = ex / len; - let d = (len * 0.25).min(1.0e-3); let mx = (a.x + b.x) / 2.0; let mz = (a.z + b.z) / 2.0; + let d = probe_reach(Pt2::new(mx, mz), len, 1.0e-3, sub_edges, eps); return Some(Pt2::new(mx + side * nx * d, mz + side * nz * d)); } None @@ -142,13 +198,20 @@ pub fn resolve_ring_nonzero(input: &[Pt2], eps: f64) -> Option { let loops: Vec> = decompose_self_intersections(&ring, eps) .into_iter() .map(|l| dedupe_ring(&l, eps)) - .filter(|l| l.len() >= 3 && signed_area2(l).abs() > eps) + .filter(|l| !ring_is_degenerate(l, eps)) + .collect(); + let sub_edges: Vec = loops + .iter() + .flat_map(|l| (0..l.len()).map(move |index| (l[index], l[(index + 1) % l.len()]))) .collect(); let mut outer_rings: Vec> = Vec::new(); let mut hole_rings: Vec> = Vec::new(); for l in loops { - let (Some(p_in), Some(p_out)) = (edge_probe2(&l, 1.0), edge_probe2(&l, -1.0)) else { + let (Some(p_in), Some(p_out)) = ( + edge_probe2(&l, 1.0, &sub_edges, eps), + edge_probe2(&l, -1.0, &sub_edges, eps), + ) else { continue; }; let filled_left = winding_number2(p_in, &ring) != 0; @@ -202,7 +265,7 @@ pub fn resolve_ring_nonzero(input: &[Pt2], eps: f64) -> Option { /// Union CCW polygons by the NONZERO-WINDING rule (the crossing cleanup the /// single-ring offset cannot do). -pub fn union_polygons_nonzero(polys: &[Vec], eps: f64) -> Vec { +pub fn union_polygons_nonzero(polys: &[Vec], eps: f64) -> RegionResult { winding_regions(polys, eps, |winding| winding != 0) } @@ -210,7 +273,7 @@ pub fn union_polygons_nonzero(polys: &[Vec], eps: f64) -> Vec { /// +1, each CW ring −1, and a point is filled when the sum is > 0. With one CCW /// base ring and CW cutters this is base-minus-cutters, keeping EVERY disjoint /// remainder (a clip can split the base into separate regions). -pub fn resolve_polygons_positive(polys: &[Vec], eps: f64) -> Vec { +pub fn resolve_polygons_positive(polys: &[Vec], eps: f64) -> RegionResult { winding_regions(polys, eps, |winding| winding > 0) } @@ -222,7 +285,7 @@ pub fn boolean_oriented_regions( b: &[Vec], operation: PlanarBooleanOp, eps: f64, -) -> Vec { +) -> RegionResult { let mut contours = Vec::with_capacity(a.len() + b.len()); contours.extend_from_slice(a); contours.extend_from_slice(b); @@ -245,17 +308,13 @@ pub fn boolean_oriented_regions( }) } -fn winding_regions(polys: &[Vec], eps: f64, filled: impl Fn(i32) -> bool) -> Vec { +fn winding_regions(polys: &[Vec], eps: f64, filled: impl Fn(i32) -> bool) -> RegionResult { winding_regions_by(polys, eps, |point| { filled(polys.iter().map(|poly| winding_number2(point, poly)).sum()) }) } -fn winding_regions_by( - polys: &[Vec], - eps: f64, - filled: impl Fn(Pt2) -> bool, -) -> Vec { +fn winding_regions_by(polys: &[Vec], eps: f64, filled: impl Fn(Pt2) -> bool) -> RegionResult { // 1. directed edges from all oriented polygons + the vertex set for T-touch splits. let mut edges: Vec = Vec::new(); for poly in polys { @@ -277,9 +336,9 @@ pub fn regions_from_edges_by( edges: &[Edge2], eps: f64, filled: impl Fn(Pt2) -> bool, -) -> Vec { +) -> RegionResult { if edges.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let verts: Vec = edges.iter().flat_map(|&(a, b)| [a, b]).collect(); @@ -328,11 +387,11 @@ pub fn regions_from_edges_by( if len < eps { continue; } - let probe = (len * 0.25).min(1.0e-4); let nx = -dz / len; 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 left = filled(Pt2::new(mx + nx * probe, mz + nz * probe)); let right = filled(Pt2::new(mx - nx * probe, mz - nz * probe)); if left == right { @@ -369,7 +428,7 @@ pub fn regions_from_edges_by( fresh }); if boundary.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } // 4. stitch into loops; at each vertex take the next outgoing edge by smallest @@ -386,10 +445,12 @@ pub fn regions_from_edges_by( } let mut loop_pts: Vec = Vec::new(); let mut cur = start; + let mut last = start; let mut guard = 0; while !used[cur] && guard <= boundary.len() { guard += 1; used[cur] = true; + last = cur; let (a, b) = boundary[cur]; loop_pts.push(a); let back = Pt2::new(a.x - b.x, a.z - b.z); // reverse incoming, from b toward a @@ -415,6 +476,9 @@ pub fn regions_from_edges_by( None => break, } } + if ends[last].1 != ends[start].0 { + return Err(RegionBooleanError::OpenBoundary); + } if loop_pts.len() >= 3 { raw_loops.push(loop_pts); } @@ -425,7 +489,7 @@ pub fn regions_from_edges_by( let mut holes: Vec> = Vec::new(); for raw in raw_loops { let ring = dedupe_ring(&raw, eps); - if ring.len() < 3 || signed_area2(&ring).abs() < eps { + if ring_is_degenerate(&ring, eps) { continue; } if signed_area2(&ring) > 0.0 { @@ -438,14 +502,19 @@ pub fn regions_from_edges_by( } } for hole in holes { - if let Some(region) = regions + regions .iter_mut() - .find(|region| ring_inside2(&hole, ®ion.outer, eps)) - { - region.holes.push(hole); - } + .filter(|region| ring_inside2(&hole, ®ion.outer, eps)) + .min_by(|first, second| { + signed_area2(&first.outer) + .abs() + .total_cmp(&signed_area2(&second.outer).abs()) + }) + .ok_or(RegionBooleanError::OrphanHole)? + .holes + .push(hole); } - regions + Ok(regions) } #[cfg(test)] @@ -476,7 +545,8 @@ mod tests { edges.push((p(1.0, 0.0), p(1.0, 2.0))); let regions = regions_from_edges_by(&edges, DEFAULT_EPS, |point| { point.x > 0.0 && point.x < 1.0 && point.z > 0.0 && point.z < 2.0 - }); + }) + .unwrap(); assert_eq!(regions.len(), 1); assert!((signed_area2(®ions[0].outer) - 2.0).abs() < 1.0e-9); } @@ -487,7 +557,7 @@ mod tests { fn positive_clip_inner_cutter_makes_a_hole() { let base = ccw_square(0.0, 0.0, 10.0, 10.0); let cutter = cw(ccw_square(4.0, 4.0, 6.0, 6.0)); - let regions = resolve_polygons_positive(&[base, cutter], DEFAULT_EPS); + let regions = resolve_polygons_positive(&[base, cutter], DEFAULT_EPS).unwrap(); assert_eq!(regions.len(), 1, "one annular region"); assert_eq!(regions[0].holes.len(), 1, "the cutter is its hole"); assert!((signed_area2(®ions[0].outer) - 100.0).abs() < 1e-6); @@ -503,7 +573,7 @@ mod tests { fn positive_clip_keeps_every_disjoint_remainder() { let base = ccw_square(0.0, 0.0, 10.0, 10.0); let cutter = cw(ccw_square(-1.0, 4.0, 11.0, 6.0)); - let regions = resolve_polygons_positive(&[base, cutter], DEFAULT_EPS); + let regions = resolve_polygons_positive(&[base, cutter], DEFAULT_EPS).unwrap(); assert_eq!(regions.len(), 2, "the band splits the base in two"); for region in ®ions { assert!( @@ -520,12 +590,16 @@ mod tests { fn positive_clip_total_cover_and_lone_cutter_are_empty() { let base = ccw_square(0.0, 0.0, 4.0, 4.0); let cutter = cw(ccw_square(-1.0, -1.0, 5.0, 5.0)); - assert!(resolve_polygons_positive(&[base, cutter], DEFAULT_EPS).is_empty()); + assert!(resolve_polygons_positive(&[base, cutter], DEFAULT_EPS) + .unwrap() + .is_empty()); let lone = cw(ccw_square(0.0, 0.0, 1.0, 1.0)); - assert!(resolve_polygons_positive(&[lone.clone()], DEFAULT_EPS).is_empty()); + assert!(resolve_polygons_positive(&[lone.clone()], DEFAULT_EPS) + .unwrap() + .is_empty()); assert_eq!( - union_polygons_nonzero(&[lone], DEFAULT_EPS).len(), + union_polygons_nonzero(&[lone], DEFAULT_EPS).unwrap().len(), 1, "contrast: the nonzero rule fills a lone CW ring" ); @@ -538,7 +612,7 @@ mod tests { let base = ccw_square(0.0, 0.0, 10.0, 10.0); let cutter_a = cw(ccw_square(-1.0, -1.0, 3.0, 3.0)); let cutter_b = cw(ccw_square(1.0, 1.0, 4.0, 2.0)); - let regions = resolve_polygons_positive(&[base, cutter_a, cutter_b], DEFAULT_EPS); + let regions = resolve_polygons_positive(&[base, cutter_a, cutter_b], DEFAULT_EPS).unwrap(); assert_eq!(regions.len(), 1); let region = ®ions[0]; assert!(region.holes.is_empty()); @@ -558,19 +632,19 @@ mod tests { ]; let b = vec![ccw_square(5.0, -1.0, 12.0, 5.0)]; - let union = boolean_oriented_regions(&a, &b, PlanarBooleanOp::Union, DEFAULT_EPS); + let union = boolean_oriented_regions(&a, &b, PlanarBooleanOp::Union, DEFAULT_EPS).unwrap(); assert_eq!(union.len(), 1); assert_eq!(union[0].holes.len(), 1); assert!((signed_area2(&union[0].holes[0]) + 3.0).abs() < 1e-6); let intersection = - boolean_oriented_regions(&a, &b, PlanarBooleanOp::Intersection, DEFAULT_EPS); + boolean_oriented_regions(&a, &b, PlanarBooleanOp::Intersection, DEFAULT_EPS).unwrap(); assert_eq!(intersection.len(), 1); assert!(intersection[0].holes.is_empty()); assert!((signed_area2(&intersection[0].outer) - 24.0).abs() < 1e-6); let subtraction = - boolean_oriented_regions(&a, &b, PlanarBooleanOp::Subtraction, DEFAULT_EPS); + boolean_oriented_regions(&a, &b, PlanarBooleanOp::Subtraction, DEFAULT_EPS).unwrap(); assert_eq!(subtraction.len(), 1); assert!(subtraction[0].holes.is_empty()); assert!( @@ -633,7 +707,8 @@ mod tests { std::slice::from_ref(wall), PlanarBooleanOp::Union, DEFAULT_EPS, - ); + ) + .unwrap(); merged = regions .iter() .flat_map(|region| { @@ -662,7 +737,7 @@ mod tests { let scale = 1.0e-4; let a = vec![ccw_square(0.0, 0.0, scale, scale)]; let b = vec![ccw_square(0.5 * scale, 0.0, 1.5 * scale, scale)]; - let result = boolean_oriented_regions(&a, &b, PlanarBooleanOp::Union, 1.0e-10); + let result = boolean_oriented_regions(&a, &b, PlanarBooleanOp::Union, 1.0e-10).unwrap(); assert_eq!(result.len(), 1); assert!((signed_area2(&result[0].outer) - 1.5 * scale * scale).abs() < 1.0e-14); } @@ -688,7 +763,7 @@ mod tests { PlanarBooleanOp::Subtraction, ] .map(|operation| { - let regions = boolean_oriented_regions(a, b, operation, DEFAULT_EPS); + let regions = boolean_oriented_regions(a, b, operation, DEFAULT_EPS).unwrap(); (regions.len(), total_area(®ions)) }) } @@ -732,7 +807,8 @@ mod tests { vec![p(1.5, 0.5), p(2.5, 0.5), p(2.0, 0.5)], ] { let regions = - boolean_oriented_regions(&unit, &[sliver], PlanarBooleanOp::Union, DEFAULT_EPS); + boolean_oriented_regions(&unit, &[sliver], PlanarBooleanOp::Union, DEFAULT_EPS) + .unwrap(); assert_eq!(regions.len(), 1); assert_eq!(regions[0].outer.len(), 4); assert!(regions[0].holes.is_empty()); @@ -744,7 +820,8 @@ mod tests { fn a_t_junction_splits_the_edge_it_meets() { let unit = vec![ccw_square(0.0, 0.0, 1.0, 1.0)]; let stem = vec![ccw_square(0.25, 1.0, 0.75, 2.0)]; - let regions = boolean_oriented_regions(&unit, &stem, PlanarBooleanOp::Union, DEFAULT_EPS); + let regions = + boolean_oriented_regions(&unit, &stem, PlanarBooleanOp::Union, DEFAULT_EPS).unwrap(); assert_eq!(regions.len(), 1); assert_eq!(regions[0].outer.len(), 8); assert!((total_area(®ions) - 1.5).abs() < 1.0e-12); @@ -769,14 +846,16 @@ mod tests { &[], PlanarBooleanOp::Union, DEFAULT_EPS, - ); + ) + .unwrap(); assert!(thinner.is_empty()); let wider = boolean_oriented_regions( &long(4.0 * DEFAULT_EPS), &[], PlanarBooleanOp::Union, DEFAULT_EPS, - ); + ) + .unwrap(); assert_eq!(wider.len(), 1); assert!((total_area(&wider) - 40.0 * DEFAULT_EPS).abs() < 1.0e-12); let small = boolean_oriented_regions( @@ -784,7 +863,42 @@ mod tests { &[], PlanarBooleanOp::Union, DEFAULT_EPS, - ); + ) + .unwrap(); assert_eq!(small.len(), 1); } + + fn edges_of(ring: &[Pt2]) -> Vec { + (0..ring.len()) + .map(|index| (ring[index], ring[(index + 1) % ring.len()])) + .collect() + } + + fn inside_the_unit_square(point: Pt2) -> bool { + point.x > 0.0 && point.x < 1.0 && point.z > 0.0 && point.z < 1.0 + } + + #[test] + fn an_open_chain_of_boundary_edges_is_an_error_and_not_a_ring() { + let mut edges = edges_of(&ccw_square(0.0, 0.0, 1.0, 1.0)); + edges.pop(); + assert_eq!( + regions_from_edges_by(&edges, DEFAULT_EPS, inside_the_unit_square).err(), + Some(RegionBooleanError::OpenBoundary) + ); + assert_eq!( + RegionBooleanError::OpenBoundary.to_string(), + "Boolean regions produced an open boundary" + ); + } + + #[test] + fn a_hole_that_no_region_contains_is_an_error() { + let edges = edges_of(&ccw_square(0.0, 0.0, 1.0, 1.0)); + assert_eq!( + regions_from_edges_by(&edges, DEFAULT_EPS, |point| !inside_the_unit_square(point)) + .err(), + Some(RegionBooleanError::OrphanHole) + ); + } } diff --git a/main/opengeometry/src/geometry/offset_regions.rs b/main/opengeometry/src/geometry/offset_regions.rs index 1cdbdfe..568ab9c 100644 --- a/main/opengeometry/src/geometry/offset_regions.rs +++ b/main/opengeometry/src/geometry/offset_regions.rs @@ -58,6 +58,7 @@ pub fn boolean_offset_regions( let b_contours = contours(b)?; Ok( boolean_oriented_regions(&a_contours, &b_contours, operation, DEFAULT_EPS) + .map_err(|error| error.to_string())? .into_iter() .map(|region| finalize_region(®ion.outer, ®ion.holes, base_y)) .collect(), @@ -233,6 +234,29 @@ mod planar_boolean_export_tests { } } + #[test] + fn exported_boolean_reports_a_boundary_that_cannot_be_closed() { + let long = vec![( + ring(&[(0.0, 0.0), (0.0, 1.0), (1000.0, 1.0), (1000.0, 0.0)]), + Vec::new(), + )]; + let beside_its_end = vec![( + ring(&[ + (999.9995, 1.0), + (999.9995, 2.0), + (1000.5, 2.0), + (1000.5, 1.0), + ]), + Vec::new(), + )]; + assert_eq!( + boolean_offset_regions(&long, &beside_its_end, PlanarBooleanOp::Union) + .err() + .as_deref(), + Some("Boolean regions produced an open boundary") + ); + } + #[test] fn exported_boolean_preserves_split_regions_and_winding() { let a = vec![( @@ -352,9 +376,9 @@ pub fn offset_polyline_regions( closed: bool, miter_limit: f64, eps: f64, -) -> Vec { +) -> Result, RegionBooleanError> { if width <= 0.0 || !width.is_finite() || centreline.len() < 2 { - return Vec::new(); + return Ok(Vec::new()); } let simplified = simplify_polyline(centreline, width / 2.0, closed); let base_y = simplified.first().map(|v| v.y).unwrap_or(0.0); @@ -370,17 +394,17 @@ pub fn offset_polyline_regions( if is_closed && self_intersects2(&pts, eps) { let half_width = width / 2.0; let bands = mitered_offset_bands(&pts, true, half_width, f64::INFINITY, eps); - return union_polygons_nonzero(&bands, eps) + return Ok(union_polygons_nonzero(&bands, eps)? .into_iter() .map(|region| { let outer = bevel_sharp_convex_pts(®ion.outer, half_width, miter_limit, eps); finalize_region(&outer, ®ion.holes, base_y) }) - .collect(); + .collect()); } // Simple centrelines: the single-ring analytic offset (one region, or none if degenerate). - match offset_outline(&simplified, width, is_closed, miter_limit, eps) { + let regions = match offset_outline(&simplified, width, is_closed, miter_limit, eps) { Some(outline) => { let outer = canonical_ring(&normalize_winding(&outline.outer, Winding::Cw)); let holes = outline @@ -391,7 +415,8 @@ pub fn offset_polyline_regions( vec![(outer, holes)] } None => Vec::new(), - } + }; + Ok(regions) } /// Convert a resolved 2D region (outer + holes, in Pt2) to an `OffsetRegion`: @@ -601,8 +626,9 @@ pub fn offset_ring_variable( } Ok(resolve_polygons_positive(&polys, eps) + .map_err(|error| error.to_string())? .into_iter() - .filter(|region| signed_area2(®ion.outer).abs() > eps) + .filter(|region| !ring_is_degenerate(®ion.outer, eps)) .map(|region| finalize_region(®ion.outer, ®ion.holes, base_y)) .collect()) } @@ -624,7 +650,7 @@ pub fn offset_polyline_group_regions( polylines: &[OffsetPolyline], miter_limit: f64, eps: f64, -) -> Vec { +) -> Result, RegionBooleanError> { let mut bands: Vec> = Vec::new(); let mut base_y = 0.0; let mut max_half = 0.0_f64; @@ -651,15 +677,15 @@ pub fn offset_polyline_group_regions( )); } if bands.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } - union_polygons_nonzero(&bands, eps) + Ok(union_polygons_nonzero(&bands, eps)? .into_iter() .map(|region| { let outer = bevel_sharp_convex_pts(®ion.outer, max_half, miter_limit, eps); finalize_region(&outer, ®ion.holes, base_y) }) - .collect() + .collect()) } // ── wasm boundary ──────────────────────────────────────────────────────────── @@ -726,7 +752,8 @@ pub fn offset_polyline_regions_wasm( DEFAULT_MITER_LIMIT }; - let regions = offset_polyline_regions(¢reline, width, closed, limit, DEFAULT_EPS); + let regions = offset_polyline_regions(¢reline, width, closed, limit, DEFAULT_EPS) + .map_err(|error| JsValue::from_str(&error.to_string()))?; OGOffsetRegionsResult::from_regions(regions).map_err(|error| JsValue::from_str(&error)) } @@ -764,7 +791,8 @@ pub fn offset_polyline_group_regions_wasm( }) .collect(); - let regions = offset_polyline_group_regions(&polylines, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + let regions = offset_polyline_group_regions(&polylines, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .map_err(|error| JsValue::from_str(&error.to_string()))?; OGOffsetRegionsResult::from_regions(regions).map_err(|error| JsValue::from_str(&error)) } @@ -858,13 +886,10 @@ mod tests { #[test] fn straight_offsets_by_half_width() { let centreline = vec![pt(0.0, 0.0), pt(2.0, 0.0)]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 0.3, - false, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 0.3, false, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert_eq!(outer.len(), 4, "a straight stroke is a 4-point rectangle"); assert!(holes.is_empty(), "an open stroke has no holes"); // Centreline runs along +x at z=0; offsets should be at z = ±0.15. @@ -889,7 +914,8 @@ mod tests { closed: false, }, ]; - let regions = offset_polyline_group_regions(&polylines, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + let regions = + offset_polyline_group_regions(&polylines, DEFAULT_MITER_LIMIT, DEFAULT_EPS).unwrap(); assert_eq!(regions.len(), 1, "an X-crossing merges into one region"); let (outer, holes) = ®ions[0]; assert!(holes.is_empty(), "a simple cross has no interior void"); @@ -914,13 +940,10 @@ mod tests { #[test] fn open_l_is_a_simple_ring() { let centreline = vec![pt(0.0, 0.0), pt(2.0, 0.0), pt(2.0, 2.0), pt(4.0, 2.0)]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 0.3, - false, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 0.3, false, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert!(holes.is_empty(), "open chain has no holes"); let ring: Vec = outer.iter().map(|v| Pt2::new(v.x, v.z)).collect(); assert!(ring.len() >= 4, "ring has at least 4 points"); @@ -944,13 +967,10 @@ mod tests { pt(0.0, 4.0), pt(0.0, 0.0), ]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 0.4, - true, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 0.4, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert!(outer.len() >= 4, "outer ring has at least 4 points"); assert_eq!( holes.len(), @@ -977,11 +997,13 @@ mod tests { }) }; let baseline = - offset_polyline_regions(¢reline, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + offset_polyline_regions(¢reline, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(); assert!(!baseline.is_empty(), "star offset builds"); for _ in 0..8 { let again = - offset_polyline_regions(¢reline, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + offset_polyline_regions(¢reline, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(); assert_eq!(baseline.len(), again.len(), "region count must be stable"); for ((o1, h1), (o2, h2)) in baseline.iter().zip(&again) { assert!(same(o1, o2), "outer ring must be identical across calls"); @@ -1004,13 +1026,10 @@ mod tests { pt(0.0, 1.0), pt(0.0, 0.0), ]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 1.0, - true, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 1.0, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert!( holes.is_empty(), "the interior void is consumed → no hole (solid)" @@ -1038,7 +1057,8 @@ mod tests { pt(0.0, 3.0), pt(0.0, 0.0), ]; - let regions = offset_polyline_regions(&bowtie, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + let regions = + offset_polyline_regions(&bowtie, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS).unwrap(); assert!(!regions.is_empty(), "figure-8 builds"); for (outer, _holes) in ®ions { let ring: Vec = outer.iter().map(|v| Pt2::new(v.x, v.z)).collect(); @@ -1077,13 +1097,10 @@ mod tests { #[test] fn acute_open_corner_bevels_and_stays_bounded() { let centreline = vec![pt(0.0, 0.0), pt(5.0, 0.0), pt(2.0, 1.0)]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 0.5, - false, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 0.5, false, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert!(holes.is_empty(), "open stroke has no holes"); let ring: Vec = outer.iter().map(|v| Pt2::new(v.x, v.z)).collect(); assert!( @@ -1103,13 +1120,10 @@ mod tests { #[test] fn self_overlapping_open_path_resolves_simple() { let centreline = vec![pt(0.0, 0.0), pt(5.0, 0.0), pt(1.0, 0.4)]; - let (outer, _holes) = single(offset_polyline_regions( - ¢reline, - 0.3, - false, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, _holes) = single( + offset_polyline_regions(¢reline, 0.3, false, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); let ring: Vec = outer.iter().map(|v| Pt2::new(v.x, v.z)).collect(); assert!( !self_intersects2(&ring, DEFAULT_EPS), diff --git a/main/opengeometry/tests/region_boolean_probe.rs b/main/opengeometry/tests/region_boolean_probe.rs index 72f1d22..2962238 100644 --- a/main/opengeometry/tests/region_boolean_probe.rs +++ b/main/opengeometry/tests/region_boolean_probe.rs @@ -26,7 +26,7 @@ fn framed(low: f64, high: f64) -> Vec> { } fn regions(a: &[Vec], b: &[Vec], operation: PlanarBooleanOp) -> Vec { - boolean_oriented_regions(a, b, operation, DEFAULT_EPS) + boolean_oriented_regions(a, b, operation, DEFAULT_EPS).unwrap() } fn union(a: &[Vec], b: &[Vec]) -> Vec {