Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions main/opengeometry-three/src/opengeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
7 changes: 7 additions & 0 deletions main/opengeometry-three/src/rendering/records/record-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions main/opengeometry-three/tests/browser/pages/release-idle.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenGeometry idle release test</title>
</head>
<body>
<main id="status">Loading kernel</main>
<script type="module" src="./release-idle.ts"></script>
</body>
</html>
87 changes: 87 additions & 0 deletions main/opengeometry-three/tests/browser/pages/release-idle.ts
Original file line number Diff line number Diff line change
@@ -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<THREE.BufferGeometry | THREE.Material | THREE.Texture>();
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<void> {
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<ReleaseIdleResult> {
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');
18 changes: 18 additions & 0 deletions main/opengeometry-three/tests/browser/specs/release-idle.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ReleaseIdleResult> => {
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);
});
12 changes: 12 additions & 0 deletions main/opengeometry-three/tests/browser/support/fixture-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReleaseIdleResult> };

declare global {
var ogSmoke: SmokeFixture | undefined;
var ogAcceptance: AcceptanceFixture | undefined;
var ogQuickStart: QuickStartFixture | undefined;
var ogReleaseIdle: ReleaseIdleFixture | undefined;
}
2 changes: 1 addition & 1 deletion main/opengeometry-three/tests/browser/support/test-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function statusElement(): Element {
return status;
}

export function publishFixture<K extends 'ogSmoke' | 'ogAcceptance' | 'ogQuickStart'>(
export function publishFixture<K extends 'ogSmoke' | 'ogAcceptance' | 'ogQuickStart' | 'ogReleaseIdle'>(
key: K, fixture: NonNullable<(typeof globalThis)[K]>, status: string,
): void {
globalThis[key] = fixture;
Expand Down
101 changes: 101 additions & 0 deletions main/opengeometry-three/tests/node/release-idle.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading
Loading