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