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