From effb300bf680bdd0ee0d8103e6b0b010d6eb77d6 Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Wed, 4 Nov 2026 14:34:07 +0100 Subject: [PATCH 1/9] perf(lod): observe the camera once per renderer frame (OPE-136) Every surface and outline hook observed the camera on every draw, allocating a Vector2 and a 33-value array each time. The observer now returns early when the renderer frame was already seen, reuses one buffer-size vector held in the LOD state, and compares the camera in place. OpenGeometry.observeCamera(renderer, camera) lets a host whose drawables are not kernel bodies, such as instanced or batched layers, feed the LOD from its own onBeforeRender. Signed-off-by: Vishwajeet Mane --- README.md | 2 +- main/opengeometry-three/src/opengeometry.ts | 3 + .../src/rendering/lod/camera-observer.ts | 9 ++- .../src/rendering/lod/lod-controller.ts | 23 +++++-- .../tests/node/display-bounds.test.mjs | 3 +- .../tests/node/lod.test.mjs | 66 ++++++++++++++++++- .../tests/node/sdk.test.mjs | 2 +- .../tests/node/swap-scope.test.mjs | 3 +- 8 files changed, 97 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1da9003c..766ab3c2 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)`; `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`. | +| 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.observeCamera(renderer, camera)` observes the first camera seen per renderer frame (`renderer.info.render.frame`) for level of detail; a `render()` nested inside an `onBeforeRender` (Reflector style) starts a new frame, and an `ArrayCamera` is observed through its first sub-camera (a host whose drawables are not kernel bodies, such as instanced or batched layers, calls it from their `onBeforeRender`); `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 34fa9fc6..063fcdf8 100644 --- a/main/opengeometry-three/src/opengeometry.ts +++ b/main/opengeometry-three/src/opengeometry.ts @@ -18,6 +18,7 @@ import { booleanCurvedRegions2D, booleanRegions2D, insetCurvedRegion2D } from '. import { resolveHit } from './picking/resolve-hit'; import { flush } from './rendering/geometry/geometry-scheduler'; import { settled } from './rendering/geometry/settle'; +import { observeCamera } from './rendering/lod/camera-observer'; import { motionHint, overrideChanged } from './rendering/lod/lod-controller'; import { createRuntimeOnce } from './runtime/create-runtime'; import { on } from './runtime/event-bus'; @@ -55,6 +56,8 @@ export class OpenGeometry { if (moving) motionHint(state, Date.now()); } + static observeCamera(renderer: THREE.WebGLRenderer, camera: THREE.Camera): void { observeCamera(renderer, camera); } + static resolveHit( hit: THREE.Intersection, ): { ogId: string; shapeRevision: number; faceId?: number; edgeId?: number } | undefined { diff --git a/main/opengeometry-three/src/rendering/lod/camera-observer.ts b/main/opengeometry-three/src/rendering/lod/camera-observer.ts index c8e1c7f0..29bc2b5b 100644 --- a/main/opengeometry-three/src/rendering/lod/camera-observer.ts +++ b/main/opengeometry-three/src/rendering/lod/camera-observer.ts @@ -1,7 +1,12 @@ -import * as THREE from 'three'; +import type * as THREE from 'three'; import { runtime } from '../../runtime/runtime-state'; import { observe } from './lod-controller'; export function observeCamera(renderer: THREE.WebGLRenderer, camera: THREE.Camera): void { - observe(runtime(), camera, renderer.getDrawingBufferSize(new THREE.Vector2()).y, Date.now()); + const state = runtime(); + const { frames, bufferSize } = state.lod; + const frame = renderer.info.render.frame; + if (frames.get(renderer) === frame) return; + frames.set(renderer, frame); + observe(state, camera, renderer.getDrawingBufferSize(bufferSize).y, Date.now()); } diff --git a/main/opengeometry-three/src/rendering/lod/lod-controller.ts b/main/opengeometry-three/src/rendering/lod/lod-controller.ts index d24b5a62..4542f2c7 100644 --- a/main/opengeometry-three/src/rendering/lod/lod-controller.ts +++ b/main/opengeometry-three/src/rendering/lod/lod-controller.ts @@ -1,4 +1,4 @@ -import type * as THREE from 'three'; +import * as THREE from 'three'; import type { Body } from '../../bodies/body'; import { call } from '../../kernel/kernel-session'; import { EVALUATION_INTERVAL_MS, MOTION_WINDOW_MS, PIXELS_AT_REST, PIXELS_MOVING } from '../../limits'; @@ -28,6 +28,8 @@ type ShapeLod = { export type LodState = { shapes: Map; camera: Float64Array; + frames: WeakMap; + bufferSize: THREE.Vector2; initialised: boolean; lastMotion: number; lastSchedule: number; @@ -41,8 +43,8 @@ export type LodState = { export function createLodState(refresh: () => void): LodState { return { - shapes: new Map(), camera: new Float64Array(33), initialised: false, lastMotion: -Infinity, - lastSchedule: -Infinity, temporaries: [], stale: true, refresh, + shapes: new Map(), camera: new Float64Array(33), frames: new WeakMap(), bufferSize: new THREE.Vector2(), + initialised: false, lastMotion: -Infinity, lastSchedule: -Infinity, temporaries: [], stale: true, refresh, }; } @@ -64,14 +66,21 @@ export function stampDisplayed(state: Runtime, body: Body): void { export function observe(state: Runtime, camera: THREE.Camera, height: number, now: number): void { const lod = state.lod; - const values = [...camera.matrixWorld.elements, ...camera.projectionMatrix.elements, height]; - const changed = values.some((value, index) => value !== lod.camera[index]); - lod.camera.set(values); - if (lod.initialised && changed) motionHint(state, now); + const moved = storeChanged(lod.camera, camera.matrixWorld.elements, 0); + const projected = storeChanged(lod.camera, camera.projectionMatrix.elements, 16); + const resized = lod.camera[32] !== height; + lod.camera[32] = height; + if (lod.initialised && (moved || projected || resized)) motionHint(state, now); lod.initialised = true; lod.lastSeen = { camera, height }; } +function storeChanged(target: Float64Array, values: readonly number[], offset: number): boolean { + const same = values.every((value, index) => target[offset + index] === value); + if (!same) target.set(values, offset); + return !same; +} + export function motionHint(state: Runtime, now: number): void { const lod = state.lod; lod.lastMotion = now; diff --git a/main/opengeometry-three/tests/node/display-bounds.test.mjs b/main/opengeometry-three/tests/node/display-bounds.test.mjs index a0a9e3c7..d0e2b24e 100644 --- a/main/opengeometry-three/tests/node/display-bounds.test.mjs +++ b/main/opengeometry-three/tests/node/display-bounds.test.mjs @@ -6,7 +6,7 @@ import { OpenGeometry, OG_TRANSFORM_TRANSLATE } from '../../../dist/index.js'; import * as TESTING from '../../../dist/testing.js'; import { boot, cuboid } from './support.mjs'; -const RENDERER = { getDrawingBufferSize: (target) => target.set(1000, 1000) }; +const RENDERER = { info: { render: { frame: 0 } }, getDrawingBufferSize: (target) => target.set(1000, 1000) }; const TIMERS = Reflect.get(NODE_TEST, 'mock').timers; const BODIES = 6; const FRAMES = 8; @@ -52,6 +52,7 @@ function orbit(camera, step) { async function frame(scene, camera) { TIMERS.tick(100); scene.updateMatrixWorld(true); + RENDERER.info.render.frame++; scene.traverseVisible((object) => { if (object instanceof THREE.Mesh) Reflect.apply(object.onBeforeRender, object, [RENDERER, scene, camera]); }); diff --git a/main/opengeometry-three/tests/node/lod.test.mjs b/main/opengeometry-three/tests/node/lod.test.mjs index 361cbbe9..7c2c763c 100644 --- a/main/opengeometry-three/tests/node/lod.test.mjs +++ b/main/opengeometry-three/tests/node/lod.test.mjs @@ -8,7 +8,7 @@ import { import * as TESTING from '../../../dist/testing.js'; import { boot, cuboid, listen } from './support.mjs'; -const RENDERER = { getDrawingBufferSize: (target) => target.set(1000, 1000) }; +const RENDERER = { info: { render: { frame: 0 } }, getDrawingBufferSize: (target) => target.set(1000, 1000) }; const P = 2 ** -10; const TIMERS = Reflect.get(NODE_TEST, 'mock').timers; @@ -30,12 +30,29 @@ function ortho(target) { async function frame(scene, camera) { scene.updateMatrixWorld(true); + RENDERER.info.render.frame++; scene.traverseVisible((object) => { if (object instanceof THREE.Mesh) Reflect.apply(object.onBeforeRender, object, [RENDERER, scene, camera]); }); await Promise.resolve(); } +async function hostFrame(scene, camera) { + scene.updateMatrixWorld(true); + RENDERER.info.render.frame++; + OpenGeometry.observeCamera(RENDERER, camera); + await Promise.resolve(); +} + +function countingRenderer() { + const renderer = { + reads: 0, + info: { render: { frame: 1 } }, + getDrawingBufferSize: (target) => { renderer.reads++; return target.set(1000, 1000); }, + }; + return renderer; +} + function computeLog() { const provider = Object(TESTING.runtime().provider); const compute = provider.compute.bind(provider); @@ -302,3 +319,50 @@ test('overlapping settled calls leave no temporary target behind', async () => { body.dispose(); } }); + +test('one frame of 1,000 bodies observes the camera once', async () => { + await fresh(); + const bodies = Array.from({ length: 1000 }, (_, index) => cuboid(`observed-cube-${String(index)}`)); + const renderer = countingRenderer(); + const camera = ortho(1.5 * P); + try { + for (const { surface } of bodies) Reflect.apply(surface.onBeforeRender, surface, [renderer, null, camera]); + assert.equal(renderer.reads, 1); + renderer.info.render.frame++; + for (const { surface } of bodies) Reflect.apply(surface.onBeforeRender, surface, [renderer, null, camera]); + assert.equal(renderer.reads, 2); + } finally { + for (const body of bodies) body.dispose(); + } +}); + +test('a host call with no body hooks moves the LOD target', async () => { + await fresh(); + TIMERS.enable({ apis: ['setTimeout', 'Date'] }); + const scene = new THREE.Scene(); + const body = cuboid('host-observed-cube'); + scene.add(body); + const camera = ortho(1.5 * P); + try { + await hostFrame(scene, camera); + assert.equal(body.record?.bucket, P); + await hostFrame(scene, aim(camera, 4.00001 * P)); + assert.equal(TESTING.runtime().lod.stale, true); + TIMERS.tick(151); + assert.equal(body.record?.bucket, 4 * P); + } finally { + TIMERS.reset(); + body.dispose(); + } +}); + +test('a new runtime observes a frame its reset predecessor already saw', async () => { + await fresh(); + const renderer = countingRenderer(); + OpenGeometry.observeCamera(renderer, ortho(P)); + OpenGeometry.reset(); + assert.equal(TESTING.runtime().lod.lastSeen, undefined); + OpenGeometry.observeCamera(renderer, ortho(P)); + assert.equal(renderer.reads, 2); + assert.notEqual(TESTING.runtime().lod.lastSeen, undefined); +}); diff --git a/main/opengeometry-three/tests/node/sdk.test.mjs b/main/opengeometry-three/tests/node/sdk.test.mjs index ec9f939a..eb861939 100644 --- a/main/opengeometry-three/tests/node/sdk.test.mjs +++ b/main/opengeometry-three/tests/node/sdk.test.mjs @@ -85,7 +85,7 @@ test('display buckets are read once per shape and revision', () => { camera.position.set(3, 3, 3); camera.lookAt(0, 0, 0); camera.updateMatrixWorld(); - const renderer = { getDrawingBufferSize: (size) => size.set(800, 600) }; + const renderer = { info: { render: { frame: 1 } }, getDrawingBufferSize: (size) => size.set(800, 600) }; try { for (let index = 0; index < 100; index++) wanted(sized); assert.equal(reads, 1); diff --git a/main/opengeometry-three/tests/node/swap-scope.test.mjs b/main/opengeometry-three/tests/node/swap-scope.test.mjs index 79587cda..0183a472 100644 --- a/main/opengeometry-three/tests/node/swap-scope.test.mjs +++ b/main/opengeometry-three/tests/node/swap-scope.test.mjs @@ -8,7 +8,7 @@ import { ensureGeometry, graph, runtime } from '../../../dist/testing.js'; import { boot, cuboid, nextTask } from './support.mjs'; const LIVE = 300; -const RENDERER = { getDrawingBufferSize: (target) => target.set(1000, 1000) }; +const RENDERER = { info: { render: { frame: 0 } }, getDrawingBufferSize: (target) => target.set(1000, 1000) }; const BODY = Object.getPrototypeOf(Solid.prototype); async function fresh() { @@ -227,6 +227,7 @@ function camera() { async function frame(scene, view) { scene.updateMatrixWorld(true); const mesh = scene.children[0]?.surface; + RENDERER.info.render.frame++; if (mesh) Reflect.apply(mesh.onBeforeRender, mesh, [RENDERER, scene, view]); await nextTask(); } From c88fca3d9db2bb836cb4c8931b55648260d43a64 Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Wed, 4 Nov 2026 14:34:37 +0100 Subject: [PATCH 2/9] perf(body): keep the surface and outline local matrices static (OPE-135) A forced matrix walk composed both display children of every body on every frame although their local transform only changes when a geometry record is installed. The children now have matrixAutoUpdate off and install updates their local matrices after placing the record origin. The migration guide records the behaviour change for code that writes those meshes' transforms. Signed-off-by: Vishwajeet Mane --- MIGRATION.md | 6 ++ main/opengeometry-three/src/bodies/body.ts | 4 ++ .../tests/node/static-children.test.mjs | 64 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 main/opengeometry-three/tests/node/static-children.test.mjs diff --git a/MIGRATION.md b/MIGRATION.md index 0fdbf849..d18301cf 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,6 +12,12 @@ It is written for people and for coding assistants alike: each section says what 4. For a name the table does not list, look in "Partly replaced". Anything listed under "Removed" has no replacement: take that code out, or stay on the older version. 5. Finish with its "Check your result" steps. +## Unreleased + +### Different behaviour + +- A body's `surface` and `outline` meshes are static: their `matrixAutoUpdate` is `false`, so a world matrix update no longer recomputes their local matrix. Code that writes their `position`, `rotation`, `quaternion` or `scale` must call `updateMatrix()` on that mesh after the write. + ## 2.5 to 3.0 2.5 was not published. From 2.0, follow 2.0 to 2.5, then 2.5 to 3.0. diff --git a/main/opengeometry-three/src/bodies/body.ts b/main/opengeometry-three/src/bodies/body.ts index e56a1088..8414637e 100644 --- a/main/opengeometry-three/src/bodies/body.ts +++ b/main/opengeometry-three/src/bodies/body.ts @@ -74,10 +74,12 @@ export abstract class Body extends THREE.Group { const surfaceStyle = surfaceMaterial(this.appearance); this.surfaceKey = surfaceStyle.key; this.surface = new THREE.Mesh(placeholder ?? new THREE.BufferGeometry(), surfaceStyle.material); + this.surface.matrixAutoUpdate = false; this.surface.visible = Boolean(placeholder) && bodyType !== 'Wire'; const outlineStyle = lineMaterial(lineStyle(bodyType, this.appearance)); this.lineKey = outlineStyle.key; this.outline = new THREE.LineSegments(placeholder ?? new THREE.BufferGeometry(), outlineStyle.material); + this.outline.matrixAutoUpdate = false; this.outline.visible = Boolean(placeholder) && this.appearance.outline; if (!this.appearance.pickOutline) this.outline.raycast = THREE.Object3D.prototype.raycast.bind(this.outline); this.add(this.surface, this.outline); @@ -121,6 +123,8 @@ export abstract class Body extends THREE.Group { this.outline.geometry = record.outline; this.surface.position.copy(record.origin); this.outline.position.copy(record.origin); + this.surface.updateMatrix(); + this.outline.updateMatrix(); this.surface.updateMatrixWorld(); this.outline.updateMatrixWorld(); this.surface.visible = this.bodyType !== 'Wire'; diff --git a/main/opengeometry-three/tests/node/static-children.test.mjs b/main/opengeometry-three/tests/node/static-children.test.mjs new file mode 100644 index 00000000..cae6b7d6 --- /dev/null +++ b/main/opengeometry-three/tests/node/static-children.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import * as THREE from 'three'; +import { OpenGeometry, Solid, OG_PRIMITIVE_CUBOID } from '../../../dist/index.js'; +import { boot } from './support.mjs'; + +async function fresh() { + await boot(); + OpenGeometry.reset(); +} + +function placedBox() { + return new Solid(OG_PRIMITIVE_CUBOID, { width: 2, height: 1, depth: 3 }, { + plane: { origin: [2, -1, 0.5], normal: [0, 0, 1], xDirection: [1, 0, 0] }, + }); +} + +function childUpdatesDuring(body, step) { + const update = THREE.Object3D.prototype.updateMatrix; + const children = new Set([body.surface, body.outline]); + let calls = 0; + THREE.Object3D.prototype.updateMatrix = function counted(...rest) { + if (children.has(this)) calls++; + return Reflect.apply(update, this, rest); + }; + try { + step(); + } finally { + THREE.Object3D.prototype.updateMatrix = update; + } + return calls; +} + +function installed(body) { + const scene = new THREE.Scene(); + scene.add(body); + scene.updateMatrixWorld(true); + OpenGeometry.flush({ geometry: 'sync' }); + const record = body.record; + assert(record, 'the body has an installed record'); + return record; +} + +test('a forced world walk from a body updates no local matrix of its surface or outline', async () => { + await fresh(); + const body = placedBox(); + installed(body); + assert.equal(childUpdatesDuring(body, () => { body.updateMatrixWorld(true); }), 0); +}); + +test('an installed record places the surface and outline at the record origin in world space', async () => { + await fresh(); + const body = placedBox(); + const record = installed(body); + assert.notEqual(record.origin.length(), 0); + const expected = record.origin.clone().applyMatrix4(body.matrixWorld); + for (const child of [body.surface, body.outline]) { + const actual = new THREE.Vector3().setFromMatrixPosition(child.matrixWorld); + assert(actual.distanceTo(expected) < 1e-9, `${child.type} at ${actual.toArray().join(',')}`); + } + body.updateMatrixWorld(true); + const walked = new THREE.Vector3().setFromMatrixPosition(body.surface.matrixWorld); + assert(walked.distanceTo(expected) < 1e-9); +}); From 2a3176bd5ada9409c3f0b1de4a1f00864c7b63ff Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Wed, 4 Nov 2026 14:49:05 +0100 Subject: [PATCH 3/9] perf(render): flush once per render pass (OPE-134) Every displayed body flushed the graph on its matrix walk, one revision read across the WASM boundary per body per frame. The pass now flushes when it starts, and again only after a transform, addChild or removeChild made inside the pass, so an edit followed by a matrix update in the same task still reaches three.js. The end-of-pass flush for ready shapes is unchanged. Signed-off-by: Vishwajeet Mane --- main/opengeometry-three/src/bodies/body.ts | 4 +- .../src/bodies/node-methods.ts | 7 + .../src/bodies/system-assembly.ts | 4 +- .../rendering/geometry/geometry-scheduler.ts | 1 + .../src/rendering/geometry/render-pass.ts | 5 +- .../src/runtime/create-runtime.ts | 2 +- .../src/runtime/runtime-state.ts | 1 + .../tests/node/flush.test.mjs | 123 +++++++++++++++++- 8 files changed, 136 insertions(+), 11 deletions(-) diff --git a/main/opengeometry-three/src/bodies/body.ts b/main/opengeometry-three/src/bodies/body.ts index 8414637e..4e911475 100644 --- a/main/opengeometry-three/src/bodies/body.ts +++ b/main/opengeometry-three/src/bodies/body.ts @@ -24,7 +24,7 @@ import { register, unregister } from './body-registry'; import { DisplayClone } from './display-clone'; import { addChild, checkNode, classifyPoint, faceNormalAt, getBounds, getBrep, getChildren, getInstanceCount, getParent, - getPlacement, getWorldBrep, getWorldPlacement, localBounds, measure, mirror, removeChild, validity, + getPlacement, getWorldBrep, getWorldPlacement, localBounds, measure, mirror, removeChild, transformNode, validity, } from './node-methods'; import type { PointClassification } from './node-methods'; import type { SystemAssembly } from './system-assembly'; @@ -157,7 +157,7 @@ export abstract class Body extends THREE.Group { transform(kind: string, params: Record): void { this.check(`${this.bodyType}.transform`); - call(`${this.bodyType}.transform`, () => worldGraph().transform(this.ogId, encode({ kind, ...params }))); + transformNode(this.bodyType, this.ogId, kind, params); } getPlacement(): Placement { diff --git a/main/opengeometry-three/src/bodies/node-methods.ts b/main/opengeometry-three/src/bodies/node-methods.ts index 8afc3963..4ecdd6eb 100644 --- a/main/opengeometry-three/src/bodies/node-methods.ts +++ b/main/opengeometry-three/src/bodies/node-methods.ts @@ -27,12 +27,19 @@ export function getWorldPlacement(owner: string, ogId: string): Placement { return decodePlacement(call(label, () => worldGraph().worldPlacement(ogId)), label); } +export function transformNode(owner: string, ogId: string, kind: string, params: Record): void { + call(`${owner}.transform`, () => worldGraph().transform(ogId, encode({ kind, ...params }))); + runtime().graphMoved = true; +} + export function addChild(owner: string, ogId: string, children: { ogId: string }[], keepWorld: boolean): void { call(`${owner}.addChild`, () => worldGraph().addChild(ogId, encode(children.map((child) => child.ogId)), keepWorld)); + runtime().graphMoved = true; } export function removeChild(owner: string, ogId: string, childId: string, keepWorld: boolean): void { call(`${owner}.removeChild`, () => worldGraph().removeChild(ogId, childId, keepWorld)); + runtime().graphMoved = true; } export function getChildren(owner: string, ogId: string): string[] { diff --git a/main/opengeometry-three/src/bodies/system-assembly.ts b/main/opengeometry-three/src/bodies/system-assembly.ts index 23b8468a..2a9fd774 100644 --- a/main/opengeometry-three/src/bodies/system-assembly.ts +++ b/main/opengeometry-three/src/bodies/system-assembly.ts @@ -6,7 +6,7 @@ import { encode } from '../world-graph/codec'; import { creation, node, worldGraph } from '../world-graph/world-graph-client'; import type { Body } from './body'; import { - addChild, checkNode, getBounds, getChildren, getParent, getPlacement, getWorldPlacement, removeChild, + addChild, checkNode, getBounds, getChildren, getParent, getPlacement, getWorldPlacement, removeChild, transformNode, } from './node-methods'; export class SystemAssembly { @@ -28,7 +28,7 @@ export class SystemAssembly { transform(kind: string, params: Record): void { this.check('SystemAssembly.transform'); - call('SystemAssembly.transform', () => worldGraph().transform(this.ogId, encode({ kind, ...params }))); + transformNode('SystemAssembly', this.ogId, kind, params); } getPlacement(): Placement { diff --git a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts index 00ef8409..38078170 100644 --- a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts +++ b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts @@ -56,6 +56,7 @@ export function flushForDispose(): void { function applyFlush(state: Runtime): void { const revision = call('OpenGeometry.flush', () => state.graph.revision()); + state.graphMoved = false; if (!state.flushing && (revision !== state.revision || (!state.renderPassActive && state.readyShapes.size > 0))) { state.flushing = true; try { diff --git a/main/opengeometry-three/src/rendering/geometry/render-pass.ts b/main/opengeometry-three/src/rendering/geometry/render-pass.ts index 7ec9bef6..d1ec8647 100644 --- a/main/opengeometry-three/src/rendering/geometry/render-pass.ts +++ b/main/opengeometry-three/src/rendering/geometry/render-pass.ts @@ -5,12 +5,13 @@ import { ensureGeometry, flush } from './geometry-scheduler'; export function noteDisplayed(body: Body): void { const state = runtime(); - if (!state.renderPassActive) { + const starting = !state.renderPassActive; + if (starting) { state.renderPassActive = true; queueMicrotask(() => { endPass(state); }); } stampDisplayed(state, body); - flush(); + if (starting || state.graphMoved) flush(); ensureGeometry(body); } diff --git a/main/opengeometry-three/src/runtime/create-runtime.ts b/main/opengeometry-three/src/runtime/create-runtime.ts index c8462181..217e8579 100644 --- a/main/opengeometry-three/src/runtime/create-runtime.ts +++ b/main/opengeometry-three/src/runtime/create-runtime.ts @@ -46,7 +46,7 @@ export function createRuntime(module: WebAssembly.Module, options: CreateOptions const state: Runtime = { graph, provider, records: new RecordPool(), bodies: new Map(), pickTargets: new WeakMap(), byShape: new Map(), limbo: new Map(), listeners, - revision: 0n, readyShapes: new Set(), renderPassActive: false, pass: 0, displayed: new Map(), + revision: 0n, readyShapes: new Set(), renderPassActive: false, graphMoved: false, pass: 0, displayed: new Map(), pending: new Map(), generations: new Map(), shapeBuckets: new Map(), displayBounds: new Map(), lod: createLodState(() => { refreshDisplayed(state); }), module, createOptions: options, poisoned: false, flushes: 0, marks: new Map(), flushing: false, surfacePool: new Map(), linePool: new Map(), epoch: nextEpoch(), diff --git a/main/opengeometry-three/src/runtime/runtime-state.ts b/main/opengeometry-three/src/runtime/runtime-state.ts index daeb765f..24845a7a 100644 --- a/main/opengeometry-three/src/runtime/runtime-state.ts +++ b/main/opengeometry-three/src/runtime/runtime-state.ts @@ -25,6 +25,7 @@ export type Runtime = { revision: bigint; readyShapes: Set; renderPassActive: boolean; + graphMoved: boolean; pass: number; displayed: Map; pending: Map>; diff --git a/main/opengeometry-three/tests/node/flush.test.mjs b/main/opengeometry-three/tests/node/flush.test.mjs index 405a6320..8774e355 100644 --- a/main/opengeometry-three/tests/node/flush.test.mjs +++ b/main/opengeometry-three/tests/node/flush.test.mjs @@ -2,9 +2,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import * as THREE from 'three'; import { - OpenGeometry, Solid, OG_OPERATION_SHELL, OG_OPERATION_SUBTRACT, OG_TRANSFORM_TRANSLATE, + OpenGeometry, Solid, OG_OPERATION_SHELL, OG_OPERATION_SUBTRACT, OG_PRIMITIVE_CUBOID, OG_TRANSFORM_TRANSLATE, } from '../../../dist/index.js'; -import { graph, runtime } from '../../../dist/testing.js'; +import { flushCount, graph, runtime } from '../../../dist/testing.js'; import { boot, cuboid, nextTask } from './support.mjs'; function cuboids(prefix) { @@ -57,14 +57,31 @@ test('flush purges records only for the shapes in the change set', async () => { assert(calls <= 1, `${String(calls)} purge calls`); }); -async function shown(prefix) { +async function shownScene(prefix) { const bodies = cuboids(prefix); const scene = new THREE.Scene(); scene.add(...bodies); scene.updateMatrixWorld(true); OpenGeometry.flush({ geometry: 'sync' }); await nextTask(); - return bodies; + return { scene, bodies }; +} + +async function shown(prefix) { + return (await shownScene(prefix)).bodies; +} + +function revisionReadsDuring(step) { + const target = Object(graph()); + const revision = target.revision; + let calls = 0; + target.revision = (...args) => { calls++; return Reflect.apply(revision, target, args); }; + try { + step(); + } finally { + delete target.revision; + } + return calls; } function swapsDuring(step) { @@ -96,3 +113,101 @@ test('operate flushes with scope changed: only the operated bodies swap', async disposeAll(bodies); } }); + +test('one render pass over 50 displayed bodies reads the graph revision once', async () => { + await boot(); + await nextTask(); + const { scene, bodies } = await shownScene('one-read'); + try { + const reads = revisionReadsDuring(() => scene.updateMatrixWorld(true)); + assert.equal(reads, 1, `${String(reads)} revision reads in one pass over ${String(bodies.length)} bodies`); + await nextTask(); + } finally { + disposeAll(bodies); + } +}); + +test('a graph change between two passes is applied before the next pass installs geometry', async () => { + await boot(); + await nextTask(); + const { scene, bodies } = await shownScene('between'); + const [edited] = bodies; + assert(edited); + const late = cuboid('between-late', 2); + try { + edited.rebuild(OG_PRIMITIVE_CUBOID, { width: 3, height: 1, depth: 1 }); + edited.transform(OG_TRANSFORM_TRANSLATE, { offset: [2, 0, 0] }); + scene.add(late); + const flushes = flushCount(); + const installs = []; + edited.install = (record) => { + installs.push({ revision: record.revision, current: edited.lastInfo.shapeRevision, flushes: flushCount() }); + return Reflect.apply(Object.getPrototypeOf(edited).install, edited, [record]); + }; + scene.updateMatrixWorld(true); + delete edited.install; + assert.equal(flushCount(), flushes + 1); + assert.equal(installs[0]?.flushes, flushes + 1); + assert.equal(installs[0]?.revision, installs[0]?.current); + assert.equal(edited.matrixWorld.elements[12], 2); + assert.equal(late.record?.revision, late.lastInfo.shapeRevision); + await nextTask(); + } finally { + disposeAll([...bodies, late]); + } +}); + +test('a transform after a pass started in the same task moves the body at its next matrix update', async () => { + await boot(); + await nextTask(); + const { scene, bodies } = await shownScene('mid-transform'); + const [moved] = bodies; + assert(moved); + try { + scene.updateMatrixWorld(true); + moved.transform(OG_TRANSFORM_TRANSLATE, { offset: [5, 0, 0] }); + moved.updateMatrixWorld(true); + assert.equal(moved.matrixWorld.elements[12], 5); + await nextTask(); + } finally { + disposeAll(bodies); + } +}); + +test('an addChild after a pass started in the same task places the child at its next matrix update', async () => { + await boot(); + await nextTask(); + const { scene, bodies } = await shownScene('mid-add-child'); + const [parent, child] = bodies; + assert(parent && child); + try { + parent.transform(OG_TRANSFORM_TRANSLATE, { offset: [3, 0, 0] }); + OpenGeometry.flush(); + scene.updateMatrixWorld(true); + parent.addChild([child]); + child.updateMatrixWorld(true); + assert.equal(child.matrixWorld.elements[12], 3); + await nextTask(); + } finally { + disposeAll(bodies.filter((body) => body !== child)); + } +}); + +test('a pass with one transform in its middle reads the graph revision twice', async () => { + await boot(); + await nextTask(); + const { scene, bodies } = await shownScene('two-reads'); + const [first] = bodies; + assert(first); + try { + const reads = revisionReadsDuring(() => { + first.updateMatrixWorld(true); + first.transform(OG_TRANSFORM_TRANSLATE, { offset: [1, 0, 0] }); + scene.updateMatrixWorld(true); + }); + assert.equal(reads, 2, `${String(reads)} revision reads in one pass with one transform`); + await nextTask(); + } finally { + disposeAll(bodies); + } +}); From 4ae49001ec3172eabfd5e54b2cf4b4465929de12 Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Wed, 4 Nov 2026 15:14:57 +0100 Subject: [PATCH 4/9] perf(lod): count a body as displayed when it is drawn (OPE-137) A body was displayed only when a host walked it in the frame, so hosts had to walk every body every frame, hidden instance sources included. A body now also stamps itself from its own draw, and OpenGeometry.markDisplayed lets a host batch stamp the bodies it draws. Draw stamps install their geometry at the pass end, never inside a render; a body walked and drawn in one pass is meshed once. The walk stamp stays, so walk-then-sync callers are unchanged. Bodies dispatch typed geometrychanged after an install and placementchanged after a flush that moved them; a throwing listener is reported through the error event and the pass and flush still complete. Signed-off-by: Vishwajeet Mane --- MIGRATION.md | 1 + README.md | 2 +- main/opengeometry-three/index.ts | 1 + main/opengeometry-three/src/bodies/body.ts | 13 +- main/opengeometry-three/src/opengeometry.ts | 2 + .../rendering/geometry/geometry-scheduler.ts | 26 +- .../src/rendering/geometry/render-pass.ts | 48 ++- .../src/rendering/lod/lod-controller.ts | 5 +- .../tests/node/displayed-drawn.test.mjs | 286 ++++++++++++++++++ 9 files changed, 364 insertions(+), 20 deletions(-) create mode 100644 main/opengeometry-three/tests/node/displayed-drawn.test.mjs diff --git a/MIGRATION.md b/MIGRATION.md index d18301cf..8c144504 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -16,6 +16,7 @@ It is written for people and for coding assistants alike: each section says what ### Different behaviour +- A body counts as displayed when it is drawn, not only when `updateMatrixWorld` walks it: its surface or outline `onBeforeRender`, or `OpenGeometry.markDisplayed(bodies)`, stamps it for the current render pass, and its new display geometry is installed after that render, so the next frame shows it. A body that is neither drawn nor walked for a full pass is no longer displayed and gets no level-of-detail target or record swap. A walk still stamps and installs at once, so code that walks, then calls `OpenGeometry.flush({ geometry: 'sync' })`, gets the same meshes as before. - A body's `surface` and `outline` meshes are static: their `matrixAutoUpdate` is `false`, so a world matrix update no longer recomputes their local matrix. Code that writes their `position`, `rotation`, `quaternion` or `scale` must call `updateMatrix()` on that mesh after the write. ## 2.5 to 3.0 diff --git a/README.md b/README.md index 766ab3c2..3cfd8b24 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)`; `OpenGeometry.observeCamera(renderer, camera)` observes the first camera seen per renderer frame (`renderer.info.render.frame`) for level of detail; a `render()` nested inside an `onBeforeRender` (Reflector style) starts a new frame, and an `ArrayCamera` is observed through its first sub-camera (a host whose drawables are not kernel bodies, such as instanced or batched layers, calls it from their `onBeforeRender`); `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`. | +| 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.observeCamera(renderer, camera)` observes the first camera seen per renderer frame (`renderer.info.render.frame`) for level of detail; a `render()` nested inside an `onBeforeRender` (Reflector style) starts a new frame, and an `ArrayCamera` is observed through its first sub-camera (a host whose drawables are not kernel bodies, such as instanced or batched layers, calls it from their `onBeforeRender`); `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`; `OpenGeometry.markDisplayed(bodies)` counts those bodies as displayed for the current render pass (a host that draws bodies through its own instanced or batched layers calls it from that layer's `onBeforeRender`; it works for a body hidden by layer, skips a body with `visible = false`, and validates `bodies` as `flush({ bodies })` does). A body is displayed when it is drawn (its own `onBeforeRender` or `markDisplayed`) or walked by `updateMatrixWorld`; a draw installs new display geometry after the render, a walk at once. Each body dispatches `geometrychanged` after it installs new display geometry and `placementchanged` when a flush moves it (`BodyEventMap`); their listeners run synchronously, after the whole flush is applied or at the end of the render pass, and a listener that throws is reported through `OpenGeometry.on('error', …)` with the thrown value while the flush and the pass complete. | | 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/index.ts b/main/opengeometry-three/index.ts index ebccc1a5..8e097a64 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -4,6 +4,7 @@ export { SystemAssembly } from './src/bodies/system-assembly'; export { Wire } from './src/bodies/wire'; export { Solid } from './src/bodies/solid'; export { Sheet } from './src/bodies/sheet'; +export type { BodyEventMap } from './src/bodies/body'; export type { PointClassification } from './src/bodies/node-methods'; export type { Measure, Validity } from './src/dto/measure'; export type { MirrorPlane } from './src/dto/mirror'; diff --git a/main/opengeometry-three/src/bodies/body.ts b/main/opengeometry-three/src/bodies/body.ts index 4e911475..b1141859 100644 --- a/main/opengeometry-three/src/bodies/body.ts +++ b/main/opengeometry-three/src/bodies/body.ts @@ -7,8 +7,8 @@ import type { Placement } from '../dto/placement'; import { OGError } from '../errors'; import { call } from '../kernel/kernel-session'; import { enterLimbo, revive } from '../marks/limbo'; -import { flush, flushForDispose, wanted } from '../rendering/geometry/geometry-scheduler'; -import { noteDisplayed } from '../rendering/geometry/render-pass'; +import { flush, flushForDispose, notifyBody, wanted } from '../rendering/geometry/geometry-scheduler'; +import { noteDisplayed, noteDrawn } from '../rendering/geometry/render-pass'; import { observeCamera } from '../rendering/lod/camera-observer'; import { isDisplayed, overrideChanged } from '../rendering/lod/lod-controller'; import { @@ -38,7 +38,9 @@ function lineStyle(bodyType: 'Wire' | 'Sheet' | 'Solid', appearance: Appearance) return { color: bodyType === 'Wire' ? appearance.color : 0x243040, opacity: appearance.opacity }; } -export abstract class Body extends THREE.Group { +export type BodyEventMap = THREE.Object3DEventMap & { geometrychanged: object; placementchanged: object }; + +export abstract class Body extends THREE.Group { readonly ogId: string; readonly handle: number; readonly generation: number; @@ -83,8 +85,8 @@ export abstract class Body extends THREE.Group { this.outline.visible = Boolean(placeholder) && this.appearance.outline; if (!this.appearance.pickOutline) this.outline.raycast = THREE.Object3D.prototype.raycast.bind(this.outline); this.add(this.surface, this.outline); - this.surface.onBeforeRender = (renderer, _scene, camera) => { observeCamera(renderer, camera); }; - this.outline.onBeforeRender = (renderer, _scene, camera) => { observeCamera(renderer, camera); }; + this.surface.onBeforeRender = (renderer, _scene, camera) => { observeCamera(renderer, camera); noteDrawn(this); }; + this.outline.onBeforeRender = (renderer, _scene, camera) => { observeCamera(renderer, camera); noteDrawn(this); }; register(this); this.applyWorldMatrix(call(`${bodyType}.constructor`, () => worldGraph().worldMatrix(ogId))); } @@ -129,6 +131,7 @@ export abstract class Body extends THREE.Group { this.outline.updateMatrixWorld(); this.surface.visible = this.bodyType !== 'Wire'; this.outline.visible = this.appearance.outline; + notifyBody(this, 'geometrychanged'); } setAppearance(partial: Partial): void { diff --git a/main/opengeometry-three/src/opengeometry.ts b/main/opengeometry-three/src/opengeometry.ts index 063fcdf8..75f6dffe 100644 --- a/main/opengeometry-three/src/opengeometry.ts +++ b/main/opengeometry-three/src/opengeometry.ts @@ -17,6 +17,7 @@ import { offsetPolyline, offsetPolylineRegions, offsetRingVariable } from './ope import { booleanCurvedRegions2D, booleanRegions2D, insetCurvedRegion2D } from './operations/regions'; import { resolveHit } from './picking/resolve-hit'; import { flush } from './rendering/geometry/geometry-scheduler'; +import { markDisplayed } from './rendering/geometry/render-pass'; import { settled } from './rendering/geometry/settle'; import { observeCamera } from './rendering/lod/camera-observer'; import { motionHint, overrideChanged } from './rendering/lod/lod-controller'; @@ -37,6 +38,7 @@ export class OpenGeometry { static on(event: Events, handler: Listener): () => boolean { return on(event, handler); } static flush(options: { geometry?: 'sync'; bodies?: readonly Body[] } = {}): void { flush(options); } + static markDisplayed(bodies: readonly Body[]): void { markDisplayed(bodies); } static settled(options: { deflection?: number } = {}): Promise<{ failed: { ogId: string; error: unknown }[] }> { return settled(options); } diff --git a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts index 38078170..946dbac1 100644 --- a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts +++ b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts @@ -36,6 +36,10 @@ export function flush(options: FlushOptions = {}): void { function liveBodies(state: Runtime, bodies: readonly Body[], geometry: FlushOptions['geometry']): readonly Body[] { const label = 'OpenGeometry.flush'; if (geometry !== 'sync') throw new OGError('InvalidParameter', label, "bodies needs geometry: 'sync'"); + return checkedBodies(state, bodies, label); +} + +export function checkedBodies(state: Runtime, bodies: readonly Body[], label: string): readonly Body[] { const list: unknown = bodies; if (!Array.isArray(list)) throw new OGError('InvalidParameter', label, 'bodies must be an array of bodies'); for (const body of bodies) { @@ -44,7 +48,7 @@ function liveBodies(state: Runtime, bodies: readonly Body[], geometry: FlushOpti throw new OGError('InvalidParameter', label, 'bodies must hold only Wire, Sheet or Solid bodies'); } if (state.bodies.get(bodyKey(body.handle, body.generation)) !== body) { - throw new OGError('Disposed', 'OpenGeometry.flush', `${body.ogId} is disposed`); + throw new OGError('Disposed', label, `${body.ogId} is disposed`); } } return bodies; @@ -54,22 +58,32 @@ export function flushForDispose(): void { applyFlush(runtime()); } +export function notifyBody(body: Body, type: 'geometrychanged' | 'placementchanged'): void { + try { + body.dispatchEvent({ type }); + } catch (error) { + emit('error', error); + } +} + function applyFlush(state: Runtime): void { const revision = call('OpenGeometry.flush', () => state.graph.revision()); state.graphMoved = false; if (!state.flushing && (revision !== state.revision || (!state.renderPassActive && state.readyShapes.size > 0))) { state.flushing = true; + const moved: Body[] = []; try { const packet = call('OpenGeometry.flush', (): unknown => state.graph.changesSince(state.revision)); const { changes, matrices } = decodeChangeSet(packet, 'OpenGeometry.flush'); state.revision = revision; - applyChanges(state, changes, matrices); + moved.push(...applyChanges(state, changes, matrices)); for (const body of swapTargets(state, changes)) body.swapReadyRecord(); purgeChanged(state, changes); state.flushes++; } finally { state.flushing = false; } + for (const body of moved) notifyBody(body, 'placementchanged'); } } @@ -84,7 +98,8 @@ function swapTargets(state: Runtime, changes: ChangeSet): Body[] { return [...targets].filter((body) => isDisplayed(state, body)); } -function applyChanges(state: Runtime, changes: ChangeSet, matrices: Float64Array): void { +function applyChanges(state: Runtime, changes: ChangeSet, matrices: Float64Array): Body[] { + const moved: Body[] = []; let offset = 0; for (const change of [...changes.added, ...changes.changed]) { const body = state.bodies.get(bodyKey(change.handle, change.generation)); @@ -92,11 +107,14 @@ function applyChanges(state: Runtime, changes: ChangeSet, matrices: Float64Array const previous = body.lastInfo.shapeId; body.lastInfo = { ...body.lastInfo, shapeId: change.shapeId, shapeRevision: change.shapeRevision }; reindexShape(state, body, previous); - body.applyWorldMatrix(matrices.subarray(offset, offset + 16)); + const matrix = matrices.subarray(offset, offset + 16); + if (body.matrixWorld.elements.some((value, index) => value !== matrix[index])) moved.push(body); + body.applyWorldMatrix(matrix); } offset += 16; } for (const change of changes.removed) state.bodies.get(bodyKey(change.handle, change.generation))?.hideForDispose(); + return moved; } function purgeChanged(state: Runtime, changes: ChangeSet): void { diff --git a/main/opengeometry-three/src/rendering/geometry/render-pass.ts b/main/opengeometry-three/src/rendering/geometry/render-pass.ts index d1ec8647..1880fd01 100644 --- a/main/opengeometry-three/src/rendering/geometry/render-pass.ts +++ b/main/opengeometry-three/src/rendering/geometry/render-pass.ts @@ -1,28 +1,58 @@ import type { Body } from '../../bodies/body'; import { currentRuntime, runtime, type Runtime } from '../../runtime/runtime-state'; -import { displayedBodies, evaluatePass, stampDisplayed } from '../lod/lod-controller'; -import { ensureGeometry, flush } from './geometry-scheduler'; +import { displayedBodies, evaluatePass, isDisplayed, stampDisplayed } from '../lod/lod-controller'; +import { checkedBodies, ensureGeometry, flush } from './geometry-scheduler'; export function noteDisplayed(body: Body): void { const state = runtime(); - const starting = !state.renderPassActive; - if (starting) { - state.renderPassActive = true; - queueMicrotask(() => { endPass(state); }); - } + const starting = beginPass(state); stampDisplayed(state, body); + state.lod.walked.set(body, state.pass); + state.lod.drawn.delete(body); if (starting || state.graphMoved) flush(); ensureGeometry(body); } +export function noteDrawn(body: Body): void { + const state = runtime(); + if (!body.visible || (state.renderPassActive && state.lod.walked.get(body) === state.pass)) return; + beginPass(state); + stampDisplayed(state, body); + state.lod.drawn.add(body); +} + +export function markDisplayed(bodies: readonly Body[]): void { + const state = runtime(); + for (const body of checkedBodies(state, bodies, 'OpenGeometry.markDisplayed')) noteDrawn(body); +} + +function beginPass(state: Runtime): boolean { + if (state.renderPassActive) return false; + state.renderPassActive = true; + queueMicrotask(() => { endPass(state); }); + return true; +} + function endPass(state: Runtime): void { if (currentRuntime() !== state) return; - state.renderPassActive = false; - state.pass++; + const drawn = [...state.lod.drawn]; + state.lod.drawn.clear(); + try { + installDrawn(state, drawn); + } finally { + state.renderPassActive = false; + state.pass++; + } if (state.readyShapes.size > 0) flush(); if (evaluatePass(state, Date.now(), false)) refreshDisplayed(state); } +function installDrawn(state: Runtime, drawn: readonly Body[]): void { + if (drawn.length === 0) return; + flush(); + for (const body of drawn) if (isDisplayed(state, body)) ensureGeometry(body); +} + export function refreshDisplayed(state: Runtime): void { for (const body of displayedBodies(state)) ensureGeometry(body); } diff --git a/main/opengeometry-three/src/rendering/lod/lod-controller.ts b/main/opengeometry-three/src/rendering/lod/lod-controller.ts index 4542f2c7..a94cb4b5 100644 --- a/main/opengeometry-three/src/rendering/lod/lod-controller.ts +++ b/main/opengeometry-three/src/rendering/lod/lod-controller.ts @@ -38,13 +38,16 @@ export type LodState = { temporaries: TemporaryTarget[]; stale: boolean; evaluated?: PassInputs; + drawn: Set; + walked: WeakMap; refresh: () => void; }; export function createLodState(refresh: () => void): LodState { return { shapes: new Map(), camera: new Float64Array(33), frames: new WeakMap(), bufferSize: new THREE.Vector2(), - initialised: false, lastMotion: -Infinity, lastSchedule: -Infinity, temporaries: [], stale: true, refresh, + initialised: false, lastMotion: -Infinity, lastSchedule: -Infinity, temporaries: [], stale: true, drawn: new Set(), + walked: new WeakMap(), refresh, }; } diff --git a/main/opengeometry-three/tests/node/displayed-drawn.test.mjs b/main/opengeometry-three/tests/node/displayed-drawn.test.mjs new file mode 100644 index 00000000..6ac4446b --- /dev/null +++ b/main/opengeometry-three/tests/node/displayed-drawn.test.mjs @@ -0,0 +1,286 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import * as THREE from 'three'; +import { OpenGeometry, Solid, OG_PRIMITIVE_CUBOID, OG_TRANSFORM_TRANSLATE } from '../../../dist/index.js'; +import { graph, runtime } from '../../../dist/testing.js'; +import { boot, cuboid, listen, nextTask } from './support.mjs'; + +const RENDERER = { info: { render: { frame: 0 } }, getDrawingBufferSize: (target) => target.set(1000, 1000) }; +const CAMERA = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); +const BODY = Object.getPrototypeOf(Solid.prototype); + +async function fresh() { + await boot(); + OpenGeometry.reset(); + CAMERA.position.set(0, 0, 10); + CAMERA.updateMatrixWorld(); +} + +function draw(bodies) { + RENDERER.info.render.frame++; + for (const body of bodies) Reflect.apply(body.surface.onBeforeRender, body.surface, [RENDERER, null, CAMERA]); +} + +async function drawFrame(bodies) { + draw(bodies); + await nextTask(); +} + +function displayed(body) { + const state = runtime(); + return body.visible && (state.displayed.get(body) ?? -Infinity) >= state.pass - 1; +} + +function current(body) { + return body.record?.shapeId === body.lastInfo.shapeId && body.record?.revision === body.lastInfo.shapeRevision; +} + +function installsOn(body, step) { + const install = BODY.install; + let calls = 0; + BODY.install = function counted(...rest) { + if (this === body) calls++; + return Reflect.apply(install, this, rest); + }; + try { + step(); + } finally { + BODY.install = install; + } + return calls; +} + +function asyncProvider() { + const provider = Object(runtime().provider); + const compute = provider.compute.bind(provider); + provider.compute = undefined; + provider.request = (request) => new Promise((resolve) => { setTimeout(() => { resolve(compute(request)); }, 1); }); + return () => { delete provider.compute; delete provider.request; }; +} + +test('a body drawn once and then not drawn for two passes is not displayed and gets no swap', async () => { + await fresh(); + const [gone, kept] = [cuboid('drawn-gone'), cuboid('drawn-kept', 2)]; + try { + await drawFrame([gone, kept]); + assert.equal(displayed(gone), true, 'a drawn body is displayed'); + assert.equal(current(gone), true, 'a drawn body gets its geometry'); + await drawFrame([kept]); + await drawFrame([kept]); + assert.equal(displayed(gone), false); + const installs = installsOn(gone, () => { + gone.rebuild(OG_PRIMITIVE_CUBOID, { width: 3, height: 1, depth: 1 }); + OpenGeometry.flush(); + }); + assert.equal(installs, 0); + assert.equal(current(gone), false); + } finally { + OpenGeometry.reset(); + } +}); + +test('a body stamped only through markDisplayed on layer 30 gets its worker geometry', async () => { + await fresh(); + const restore = asyncProvider(); + const body = cuboid('marked-only'); + body.layers.set(30); + try { + OpenGeometry.markDisplayed([body]); + assert.equal(body.record, undefined, 'no install inside the draw'); + assert.equal(displayed(body), true); + for (let index = 0; index < 5 && !current(body); index++) await nextTask(); + assert.equal(current(body), true); + } finally { + restore(); + OpenGeometry.reset(); + } +}); + +test('markDisplayed rejects what flush({ bodies }) rejects and skips a hidden body', async () => { + await fresh(); + const hidden = cuboid('marked-hidden'); + const disposed = cuboid('marked-disposed'); + hidden.visible = false; + disposed.dispose(); + try { + const invalid = (error) => error?.code === 'InvalidParameter'; + assert.throws(() => OpenGeometry.markDisplayed(hidden), invalid); + assert.throws(() => OpenGeometry.markDisplayed([new THREE.Mesh()]), invalid); + assert.throws(() => OpenGeometry.markDisplayed([disposed]), (error) => error?.code === 'Disposed'); + OpenGeometry.markDisplayed([hidden]); + assert.equal(runtime().displayed.has(hidden), false); + await nextTask(); + } finally { + OpenGeometry.reset(); + } +}); + +test('a draw-stamped body installs at pass end, a walk-stamped body at once', async () => { + await fresh(); + const [drawn, walked] = [cuboid('order-drawn'), cuboid('order-walked', 2)]; + try { + const geometry = drawn.surface.geometry; + draw([drawn]); + assert.equal(drawn.record, undefined, 'no install inside the draw hook'); + assert.equal(drawn.surface.geometry, geometry); + walked.updateMatrixWorld(true); + assert.equal(current(walked), true, 'a walk in the same pass installs at once'); + await Promise.resolve(); + assert.equal(current(drawn), true, 'the drawn body installs when the pass ends'); + drawn.rebuild(OG_PRIMITIVE_CUBOID, { width: 4, height: 1, depth: 1 }); + walked.updateMatrixWorld(true); + draw([drawn]); + assert.equal(current(drawn), false, 'a walk earlier in the pass does not install a later draw stamp'); + await Promise.resolve(); + assert.equal(current(drawn), true); + } finally { + OpenGeometry.reset(); + } +}); + +async function walkedAndDrawn(prefix) { + const bodies = Array.from({ length: 50 }, (_, index) => cuboid(`${prefix}-${String(index)}`, 1 + index / 100)); + const scene = new THREE.Scene(); + scene.add(...bodies); + scene.updateMatrixWorld(true); + await drawFrame(bodies); + return { scene, bodies }; +} + +function counted(target, name) { + const original = target[name]; + const log = { calls: 0, stop: () => { Reflect.deleteProperty(target, name); } }; + target[name] = (...args) => { log.calls++; return Reflect.apply(original, target, args); }; + return log; +} + +async function realFrame(scene, bodies) { + runtime().lod.lastSchedule = Date.now(); + scene.updateMatrixWorld(true); + draw(bodies); + await Promise.resolve(); +} + +test('a walk and a draw of 50 bodies in one pass, pass end included, read the graph revision once', async () => { + await fresh(); + const { scene, bodies } = await walkedAndDrawn('reads'); + const log = counted(Object(graph()), 'revision'); + try { + await realFrame(scene, bodies); + } finally { + log.stop(); + OpenGeometry.reset(); + } + assert.equal(log.calls, 1, `${String(log.calls)} revision reads in one pass over ${String(bodies.length)} bodies`); +}); + +test('a walk and a draw of 50 bodies in one pass look up each record once', async () => { + await fresh(); + const { scene, bodies } = await walkedAndDrawn('gets'); + const log = counted(runtime().records, 'get'); + try { + await realFrame(scene, bodies); + } finally { + log.stop(); + OpenGeometry.reset(); + } + assert.equal(log.calls, bodies.length); +}); + +test('a throwing geometrychanged listener is reported and the next pass still advances', async () => { + await fresh(); + const errors = listen('error'); + const source = cuboid('wedge-source'); + try { + await drawFrame([source]); + const copy = source.instance({ ogId: 'wedge-copy' }); + OpenGeometry.flush(); + const failure = new Error('host listener'); + copy.addEventListener('geometrychanged', () => { throw failure; }); + await drawFrame([source, copy]); + const pass = runtime().pass; + const later = cuboid('wedge-later', 3); + await drawFrame([source, later]); + assert.equal(runtime().renderPassActive, false); + assert.equal(runtime().pass, pass + 1); + assert.equal(current(later), true); + assert.deepEqual(errors.events, [failure]); + } finally { + errors.stop(); + OpenGeometry.reset(); + } +}); + +test('a throwing placementchanged listener leaves the other moved bodies placed', async () => { + await fresh(); + const errors = listen('error'); + const [thrower, other] = [cuboid('placed-thrower'), cuboid('placed-other', 2)]; + const failure = new Error('placement listener'); + thrower.addEventListener('placementchanged', () => { throw failure; }); + try { + thrower.transform(OG_TRANSFORM_TRANSLATE, { offset: [1, 0, 0] }); + other.transform(OG_TRANSFORM_TRANSLATE, { offset: [5, 0, 0] }); + OpenGeometry.flush(); + assert.equal(thrower.matrixWorld.elements[12], 1); + assert.equal(other.matrixWorld.elements[12], 5); + assert.deepEqual(errors.events, [failure]); + OpenGeometry.flush(); + assert.equal(errors.events.length, 1); + } finally { + errors.stop(); + OpenGeometry.reset(); + } +}); + +test('geometrychanged fires once per install after the geometry is set', async () => { + await fresh(); + const body = cuboid('event-geometry'); + const seen = []; + body.addEventListener('geometrychanged', () => { seen.push(body.surface.geometry === body.record?.surface); }); + try { + body.updateMatrixWorld(true); + OpenGeometry.flush({ geometry: 'sync' }); + body.updateMatrixWorld(true); + await nextTask(); + assert.deepEqual(seen, [true]); + body.rebuild(OG_PRIMITIVE_CUBOID, { width: 2, height: 1, depth: 1 }); + body.updateMatrixWorld(true); + await nextTask(); + assert.deepEqual(seen, [true, true]); + } finally { + OpenGeometry.reset(); + } +}); + +test('placementchanged fires once per moved body per flush', async () => { + await fresh(); + const [moved, edited] = [cuboid('event-moved'), cuboid('event-edited', 2)]; + const counts = { moved: 0, edited: 0 }; + moved.addEventListener('placementchanged', () => { counts.moved++; }); + edited.addEventListener('placementchanged', () => { counts.edited++; }); + try { + moved.transform(OG_TRANSFORM_TRANSLATE, { offset: [1, 0, 0] }); + edited.rebuild(OG_PRIMITIVE_CUBOID, { width: 3, height: 1, depth: 1 }); + OpenGeometry.flush(); + OpenGeometry.flush(); + assert.deepEqual(counts, { moved: 1, edited: 0 }); + } finally { + OpenGeometry.reset(); + } +}); + +test('a host that walks its bodies and then syncs gets meshes for every walked body', async () => { + await fresh(); + const bodies = Array.from({ length: 5 }, (_, index) => cuboid(`walk-sync-${String(index)}`, 1 + index / 10)); + const scene = new THREE.Scene(); + scene.add(...bodies); + try { + scene.updateMatrixWorld(true); + OpenGeometry.flush({ geometry: 'sync' }); + assert.deepEqual(bodies.map(current), bodies.map(() => true)); + assert(bodies.every((body) => (body.record?.triangles ?? 0) > 0)); + await nextTask(); + } finally { + OpenGeometry.reset(); + } +}); From e6d72d9e6425d31e427b0b1d70391e86a241185d Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Wed, 4 Nov 2026 20:28:28 +0100 Subject: [PATCH 5/9] test(node): pass wrong input to the checked API through Reflect so the typecheck passes Signed-off-by: Vishwajeet Mane --- .../tests/node/displayed-drawn.test.mjs | 10 +++++----- main/opengeometry-three/tests/node/flush.test.mjs | 2 +- main/opengeometry-three/tests/node/lod.test.mjs | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/main/opengeometry-three/tests/node/displayed-drawn.test.mjs b/main/opengeometry-three/tests/node/displayed-drawn.test.mjs index 6ac4446b..1143c3f7 100644 --- a/main/opengeometry-three/tests/node/displayed-drawn.test.mjs +++ b/main/opengeometry-three/tests/node/displayed-drawn.test.mjs @@ -3,7 +3,7 @@ import { test } from 'node:test'; import * as THREE from 'three'; import { OpenGeometry, Solid, OG_PRIMITIVE_CUBOID, OG_TRANSFORM_TRANSLATE } from '../../../dist/index.js'; import { graph, runtime } from '../../../dist/testing.js'; -import { boot, cuboid, listen, nextTask } from './support.mjs'; +import { boot, cuboid, failsWith, listen, nextTask } from './support.mjs'; const RENDERER = { info: { render: { frame: 0 } }, getDrawingBufferSize: (target) => target.set(1000, 1000) }; const CAMERA = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); @@ -103,10 +103,10 @@ test('markDisplayed rejects what flush({ bodies }) rejects and skips a hidden bo hidden.visible = false; disposed.dispose(); try { - const invalid = (error) => error?.code === 'InvalidParameter'; - assert.throws(() => OpenGeometry.markDisplayed(hidden), invalid); - assert.throws(() => OpenGeometry.markDisplayed([new THREE.Mesh()]), invalid); - assert.throws(() => OpenGeometry.markDisplayed([disposed]), (error) => error?.code === 'Disposed'); + const invalid = failsWith('InvalidParameter'); + assert.throws(() => Reflect.apply(OpenGeometry.markDisplayed, OpenGeometry, [hidden]), invalid); + assert.throws(() => Reflect.apply(OpenGeometry.markDisplayed, OpenGeometry, [[new THREE.Mesh()]]), invalid); + assert.throws(() => OpenGeometry.markDisplayed([disposed]), failsWith('Disposed')); OpenGeometry.markDisplayed([hidden]); assert.equal(runtime().displayed.has(hidden), false); await nextTask(); diff --git a/main/opengeometry-three/tests/node/flush.test.mjs b/main/opengeometry-three/tests/node/flush.test.mjs index 8774e355..8e2bfba6 100644 --- a/main/opengeometry-three/tests/node/flush.test.mjs +++ b/main/opengeometry-three/tests/node/flush.test.mjs @@ -145,7 +145,7 @@ test('a graph change between two passes is applied before the next pass installs return Reflect.apply(Object.getPrototypeOf(edited).install, edited, [record]); }; scene.updateMatrixWorld(true); - delete edited.install; + Reflect.deleteProperty(edited, 'install'); assert.equal(flushCount(), flushes + 1); assert.equal(installs[0]?.flushes, flushes + 1); assert.equal(installs[0]?.revision, installs[0]?.current); diff --git a/main/opengeometry-three/tests/node/lod.test.mjs b/main/opengeometry-three/tests/node/lod.test.mjs index 7c2c763c..be54d929 100644 --- a/main/opengeometry-three/tests/node/lod.test.mjs +++ b/main/opengeometry-three/tests/node/lod.test.mjs @@ -40,7 +40,7 @@ async function frame(scene, camera) { async function hostFrame(scene, camera) { scene.updateMatrixWorld(true); RENDERER.info.render.frame++; - OpenGeometry.observeCamera(RENDERER, camera); + Reflect.apply(OpenGeometry.observeCamera, OpenGeometry, [RENDERER, camera]); await Promise.resolve(); } @@ -359,10 +359,10 @@ test('a host call with no body hooks moves the LOD target', async () => { test('a new runtime observes a frame its reset predecessor already saw', async () => { await fresh(); const renderer = countingRenderer(); - OpenGeometry.observeCamera(renderer, ortho(P)); + Reflect.apply(OpenGeometry.observeCamera, OpenGeometry, [renderer, ortho(P)]); OpenGeometry.reset(); assert.equal(TESTING.runtime().lod.lastSeen, undefined); - OpenGeometry.observeCamera(renderer, ortho(P)); + Reflect.apply(OpenGeometry.observeCamera, OpenGeometry, [renderer, ortho(P)]); assert.equal(renderer.reads, 2); assert.notEqual(TESTING.runtime().lod.lastSeen, undefined); }); From 8abfe775b6dd584102875d94468993cb2af1b50a Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Wed, 4 Nov 2026 20:28:28 +0100 Subject: [PATCH 6/9] perf(render): let a host draw a body while the kernel keeps its display state (K-06) OpenGeometry.setHostDrawn(bodies, hostDrawn): while true, three.js skips the body (visible is false) and the kernel keeps a logical visibility for stamps, LOD records, flushes and walks; giving a body back restores visible exactly and ignores a disposed body. Signed-off-by: Vishwajeet Mane --- README.md | 2 +- main/opengeometry-three/src/bodies/body.ts | 3 +- main/opengeometry-three/src/marks/limbo.ts | 7 +- main/opengeometry-three/src/opengeometry.ts | 3 +- .../rendering/geometry/geometry-scheduler.ts | 19 +- .../src/rendering/geometry/render-pass.ts | 12 +- .../src/rendering/lod/host-drawn.ts | 22 ++ .../src/rendering/lod/lod-controller.ts | 3 +- .../src/runtime/create-runtime.ts | 1 + main/opengeometry-three/src/runtime/reset.ts | 3 +- .../src/runtime/runtime-state.ts | 1 + .../tests/node/host-drawn.test.mjs | 229 ++++++++++++++++++ 12 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 main/opengeometry-three/src/rendering/lod/host-drawn.ts create mode 100644 main/opengeometry-three/tests/node/host-drawn.test.mjs diff --git a/README.md b/README.md index 3cfd8b24..b6f30b73 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)`; `OpenGeometry.observeCamera(renderer, camera)` observes the first camera seen per renderer frame (`renderer.info.render.frame`) for level of detail; a `render()` nested inside an `onBeforeRender` (Reflector style) starts a new frame, and an `ArrayCamera` is observed through its first sub-camera (a host whose drawables are not kernel bodies, such as instanced or batched layers, calls it from their `onBeforeRender`); `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`; `OpenGeometry.markDisplayed(bodies)` counts those bodies as displayed for the current render pass (a host that draws bodies through its own instanced or batched layers calls it from that layer's `onBeforeRender`; it works for a body hidden by layer, skips a body with `visible = false`, and validates `bodies` as `flush({ bodies })` does). A body is displayed when it is drawn (its own `onBeforeRender` or `markDisplayed`) or walked by `updateMatrixWorld`; a draw installs new display geometry after the render, a walk at once. Each body dispatches `geometrychanged` after it installs new display geometry and `placementchanged` when a flush moves it (`BodyEventMap`); their listeners run synchronously, after the whole flush is applied or at the end of the render pass, and a listener that throws is reported through `OpenGeometry.on('error', …)` with the thrown value while the flush and the pass complete. | +| 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.observeCamera(renderer, camera)` observes the first camera seen per renderer frame (`renderer.info.render.frame`) for level of detail; a `render()` nested inside an `onBeforeRender` (Reflector style) starts a new frame, and an `ArrayCamera` is observed through its first sub-camera (a host whose drawables are not kernel bodies, such as instanced or batched layers, calls it from their `onBeforeRender`); `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`; `OpenGeometry.markDisplayed(bodies)` counts those bodies as displayed for the current render pass (a host that draws bodies through its own instanced or batched layers calls it from that layer's `onBeforeRender`; it works for a body hidden by layer, skips a body with `visible = false`, and validates `bodies` as `flush({ bodies })` does); `OpenGeometry.setHostDrawn(bodies, hostDrawn)` hands the drawing of those bodies to the host (`true`) or gives it back (`false`): while host-drawn, a body's `visible` is `false`, so three.js skips it and everything under it, and the kernel keeps the body's own visibility apart, so `markDisplayed`, a walk, `flush({ geometry: 'sync', bodies })`, level of detail and record swaps treat it as shown, and a dispose, a rollback or a reset changes that visibility instead of `visible`; giving it back sets `visible` to that visibility (a host calls it when a body joins or leaves its instanced or batched layers; do not set `visible` on a host-drawn body; it validates `bodies` as `flush({ bodies })` does, except that giving bodies back ignores a disposed or finalized body, and a `hostDrawn` that is not a boolean throws `InvalidParameter`). A body is displayed when it is drawn (its own `onBeforeRender` or `markDisplayed`) or walked by `updateMatrixWorld`; a draw installs new display geometry after the render, a walk at once. Each body dispatches `geometrychanged` after it installs new display geometry and `placementchanged` when a flush moves it (`BodyEventMap`); their listeners run synchronously, after the whole flush is applied or at the end of the render pass, and a listener that throws is reported through `OpenGeometry.on('error', …)` with the thrown value while the flush and the pass complete. | | 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/bodies/body.ts b/main/opengeometry-three/src/bodies/body.ts index b1141859..02d67eba 100644 --- a/main/opengeometry-three/src/bodies/body.ts +++ b/main/opengeometry-three/src/bodies/body.ts @@ -10,6 +10,7 @@ import { enterLimbo, revive } from '../marks/limbo'; import { flush, flushForDispose, notifyBody, wanted } from '../rendering/geometry/geometry-scheduler'; import { noteDisplayed, noteDrawn } from '../rendering/geometry/render-pass'; import { observeCamera } from '../rendering/lod/camera-observer'; +import { shown } from '../rendering/lod/host-drawn'; import { isDisplayed, overrideChanged } from '../rendering/lod/lod-controller'; import { lineMaterial, releaseLine, releaseSurface, surfaceMaterial, type MaterialAppearance, @@ -101,7 +102,7 @@ export abstract class Body extends THREE.Group { override updateMatrixWorld(force?: boolean): void { keepPose(this.pose, this, this.ogId); - if (this.visible) noteDisplayed(this); + if (shown(runtime(), this)) noteDisplayed(this); super.updateMatrixWorld(force); } diff --git a/main/opengeometry-three/src/marks/limbo.ts b/main/opengeometry-three/src/marks/limbo.ts index beae591c..a5c6d63a 100644 --- a/main/opengeometry-three/src/marks/limbo.ts +++ b/main/opengeometry-three/src/marks/limbo.ts @@ -1,5 +1,6 @@ import type { Body } from '../bodies/body'; import { call } from '../kernel/kernel-session'; +import { setShown } from '../rendering/lod/host-drawn'; import { bodyKey, reindexShape, runtime, type Runtime } from '../runtime/runtime-state'; import { node, worldGraph } from '../world-graph/world-graph-client'; @@ -29,12 +30,12 @@ export function revive(body: Body): void { call('OGMark.rollback', () => worldGraph().nodeByHandle(body.handle, body.generation)); state.limbo.delete(body); body.inLimbo = false; - body.visible = true; + setShown(state, body, true); entry.parent?.add(body); const previous = body.lastInfo.shapeId; body.lastInfo = node(body.ogId, 'OGMark.rollback'); reindexShape(state, body, previous); - } catch { body.visible = false; } + } catch { setShown(state, body, false); } } export function finalizeNow(body: Body): void { @@ -43,7 +44,7 @@ export function finalizeNow(body: Body): void { } function hide(state: Runtime, body: Body): void { - body.visible = false; + setShown(state, body, false); state.displayed.delete(body); const parent = body.parent; if (state.flushing || state.renderPassActive) { diff --git a/main/opengeometry-three/src/opengeometry.ts b/main/opengeometry-three/src/opengeometry.ts index 75f6dffe..a6f7fe58 100644 --- a/main/opengeometry-three/src/opengeometry.ts +++ b/main/opengeometry-three/src/opengeometry.ts @@ -17,7 +17,7 @@ import { offsetPolyline, offsetPolylineRegions, offsetRingVariable } from './ope import { booleanCurvedRegions2D, booleanRegions2D, insetCurvedRegion2D } from './operations/regions'; import { resolveHit } from './picking/resolve-hit'; import { flush } from './rendering/geometry/geometry-scheduler'; -import { markDisplayed } from './rendering/geometry/render-pass'; +import { markDisplayed, setHostDrawn } from './rendering/geometry/render-pass'; import { settled } from './rendering/geometry/settle'; import { observeCamera } from './rendering/lod/camera-observer'; import { motionHint, overrideChanged } from './rendering/lod/lod-controller'; @@ -39,6 +39,7 @@ export class OpenGeometry { static on(event: Events, handler: Listener): () => boolean { return on(event, handler); } static flush(options: { geometry?: 'sync'; bodies?: readonly Body[] } = {}): void { flush(options); } static markDisplayed(bodies: readonly Body[]): void { markDisplayed(bodies); } + static setHostDrawn(bodies: readonly Body[], hostDrawn: boolean): void { setHostDrawn(bodies, hostDrawn); } static settled(options: { deflection?: number } = {}): Promise<{ failed: { ogId: string; error: unknown }[] }> { return settled(options); } diff --git a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts index 946dbac1..80295942 100644 --- a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts +++ b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts @@ -8,6 +8,7 @@ import type { Body } from '../../bodies/body'; import { emit } from '../../runtime/event-bus'; import { bodyKey, currentRuntime, reindexShape, runtime, type Runtime } from '../../runtime/runtime-state'; import { decodeChangeSet } from '../../world-graph/codec'; +import { shown } from '../lod/host-drawn'; import { clearError, displayedBodies, isDisplayed, markFailed, recordError, retryBucket, shapePriority, stampDisplayed, wantedKey, @@ -26,7 +27,7 @@ export function flush(options: FlushOptions = {}): void { applyFlush(state); if (options.geometry !== 'sync') return; for (const body of given ?? displayedBodies(state)) { - if (given && (!body.visible || !body.lastInfo.shapeId || body.lastInfo.shapeRevision === null)) continue; + if (given && (!shown(state, body) || !body.lastInfo.shapeId || body.lastInfo.shapeRevision === null)) continue; if (given) stampDisplayed(state, body); ensureGeometry(body, true); } @@ -39,19 +40,21 @@ function liveBodies(state: Runtime, bodies: readonly Body[], geometry: FlushOpti return checkedBodies(state, bodies, label); } -export function checkedBodies(state: Runtime, bodies: readonly Body[], label: string): readonly Body[] { +export function checkedBodies( + state: Runtime, bodies: readonly Body[], label: string, skipDisposed = false, +): readonly Body[] { const list: unknown = bodies; if (!Array.isArray(list)) throw new OGError('InvalidParameter', label, 'bodies must be an array of bodies'); + const live: Body[] = []; for (const body of bodies) { const item: unknown = body; if (!(item instanceof THREE.Object3D) || !('swapReadyRecord' in item)) { throw new OGError('InvalidParameter', label, 'bodies must hold only Wire, Sheet or Solid bodies'); } - if (state.bodies.get(bodyKey(body.handle, body.generation)) !== body) { - throw new OGError('Disposed', label, `${body.ogId} is disposed`); - } + if (state.bodies.get(bodyKey(body.handle, body.generation)) === body) live.push(body); + else if (!skipDisposed) throw new OGError('Disposed', label, `${body.ogId} is disposed`); } - return bodies; + return live; } export function flushForDispose(): void { @@ -150,7 +153,7 @@ export function ensureGeometry(body: Body, sync = false): void { function geometryTarget(state: Runtime, body: Body): GeometryTarget | undefined { const { shapeId, shapeRevision: revision } = body.lastInfo; - if (!shapeId || revision === null || !body.visible) return undefined; + if (!shapeId || revision === null || !shown(state, body)) return undefined; const bucket = wantedKey(state, body); if (bucket === undefined) return undefined; return { body, shapeId, revision, bucket, key: pendingKey(shapeId, revision, bucket) }; @@ -250,7 +253,7 @@ function receive(state: Runtime, target: GeometryTarget, buffers: DisplayBuffers clearError(state, shapeId, revision); if (state.renderPassActive) { for (const body of current) if (isDisplayed(state, body)) body.install(record); - } else if (target.body.visible && current.includes(target.body)) target.body.install(record); + } else if (shown(state, target.body) && current.includes(target.body)) target.body.install(record); const ogIds = holders.map((body) => body.ogId); emit('geometry', { shapeId, revision, bucket: buffers.bucket, ogIds, stats: { triangles: record.triangles } }); if (!state.renderPassActive) flush(); diff --git a/main/opengeometry-three/src/rendering/geometry/render-pass.ts b/main/opengeometry-three/src/rendering/geometry/render-pass.ts index 1880fd01..bffc4d7c 100644 --- a/main/opengeometry-three/src/rendering/geometry/render-pass.ts +++ b/main/opengeometry-three/src/rendering/geometry/render-pass.ts @@ -1,5 +1,7 @@ import type { Body } from '../../bodies/body'; +import { OGError } from '../../errors'; import { currentRuntime, runtime, type Runtime } from '../../runtime/runtime-state'; +import { handToHost, shown } from '../lod/host-drawn'; import { displayedBodies, evaluatePass, isDisplayed, stampDisplayed } from '../lod/lod-controller'; import { checkedBodies, ensureGeometry, flush } from './geometry-scheduler'; @@ -15,7 +17,7 @@ export function noteDisplayed(body: Body): void { export function noteDrawn(body: Body): void { const state = runtime(); - if (!body.visible || (state.renderPassActive && state.lod.walked.get(body) === state.pass)) return; + if (!shown(state, body) || (state.renderPassActive && state.lod.walked.get(body) === state.pass)) return; beginPass(state); stampDisplayed(state, body); state.lod.drawn.add(body); @@ -26,6 +28,14 @@ export function markDisplayed(bodies: readonly Body[]): void { for (const body of checkedBodies(state, bodies, 'OpenGeometry.markDisplayed')) noteDrawn(body); } +export function setHostDrawn(bodies: readonly Body[], hostDrawn: boolean): void { + const state = runtime(); + const label = 'OpenGeometry.setHostDrawn'; + const flag: unknown = hostDrawn; + if (typeof flag !== 'boolean') throw new OGError('InvalidParameter', label, 'hostDrawn must be a boolean'); + for (const body of checkedBodies(state, bodies, label, !hostDrawn)) handToHost(state, body, hostDrawn); +} + function beginPass(state: Runtime): boolean { if (state.renderPassActive) return false; state.renderPassActive = true; diff --git a/main/opengeometry-three/src/rendering/lod/host-drawn.ts b/main/opengeometry-three/src/rendering/lod/host-drawn.ts new file mode 100644 index 00000000..8c96a1ca --- /dev/null +++ b/main/opengeometry-three/src/rendering/lod/host-drawn.ts @@ -0,0 +1,22 @@ +import type { Body } from '../../bodies/body'; +import type { Runtime } from '../../runtime/runtime-state'; + +export function shown(state: Runtime, body: Body): boolean { + return body.visible || state.hostDrawn.get(body) === true; +} + +export function setShown(state: Runtime, body: Body, value: boolean): void { + if (state.hostDrawn.has(body)) state.hostDrawn.set(body, value); + else body.visible = value; +} + +export function handToHost(state: Runtime, body: Body, hostDrawn: boolean): void { + const logical = state.hostDrawn.get(body); + if (hostDrawn && logical === undefined) { + state.hostDrawn.set(body, body.visible); + body.visible = false; + } else if (!hostDrawn && logical !== undefined) { + state.hostDrawn.delete(body); + body.visible = logical; + } +} diff --git a/main/opengeometry-three/src/rendering/lod/lod-controller.ts b/main/opengeometry-three/src/rendering/lod/lod-controller.ts index a94cb4b5..dbd38944 100644 --- a/main/opengeometry-three/src/rendering/lod/lod-controller.ts +++ b/main/opengeometry-three/src/rendering/lod/lod-controller.ts @@ -6,6 +6,7 @@ import { currentRuntime, type Runtime } from '../../runtime/runtime-state'; import { finestLocal, localTarget, shapeBuckets, type ShapeBuckets } from './body-size'; import { deflectionBucket, wantedBucket } from './deflection'; import { displayBounds } from './display-bounds'; +import { shown } from './host-drawn'; import { worldUnitsPerPixel } from './screen-metrics'; type Tiers = { temporary: number | undefined; own: number | undefined; global: number | undefined }; @@ -52,7 +53,7 @@ export function createLodState(refresh: () => void): LodState { } export function isDisplayed(state: Runtime, body: Body): boolean { - return body.visible && (state.displayed.get(body) ?? -Infinity) >= state.pass - 1; + return shown(state, body) && (state.displayed.get(body) ?? -Infinity) >= state.pass - 1; } export function displayedBodies(state: Runtime): Body[] { diff --git a/main/opengeometry-three/src/runtime/create-runtime.ts b/main/opengeometry-three/src/runtime/create-runtime.ts index 217e8579..693be4e8 100644 --- a/main/opengeometry-three/src/runtime/create-runtime.ts +++ b/main/opengeometry-three/src/runtime/create-runtime.ts @@ -47,6 +47,7 @@ export function createRuntime(module: WebAssembly.Module, options: CreateOptions graph, provider, records: new RecordPool(), bodies: new Map(), pickTargets: new WeakMap(), byShape: new Map(), limbo: new Map(), listeners, revision: 0n, readyShapes: new Set(), renderPassActive: false, graphMoved: false, pass: 0, displayed: new Map(), + hostDrawn: new WeakMap(), pending: new Map(), generations: new Map(), shapeBuckets: new Map(), displayBounds: new Map(), lod: createLodState(() => { refreshDisplayed(state); }), module, createOptions: options, poisoned: false, flushes: 0, marks: new Map(), flushing: false, surfacePool: new Map(), linePool: new Map(), epoch: nextEpoch(), diff --git a/main/opengeometry-three/src/runtime/reset.ts b/main/opengeometry-three/src/runtime/reset.ts index cc146aad..549e3a0f 100644 --- a/main/opengeometry-three/src/runtime/reset.ts +++ b/main/opengeometry-three/src/runtime/reset.ts @@ -1,3 +1,4 @@ +import { setShown } from '../rendering/lod/host-drawn'; import { createRuntime } from './create-runtime'; import { currentRuntime, setRuntime } from './runtime-state'; @@ -5,7 +6,7 @@ export function reset(): void { const state = currentRuntime(); if (!state) return; for (const body of [...state.bodies.values()]) { - body.visible = false; + setShown(state, body, false); body.finalize(); } state.provider.dispose(); diff --git a/main/opengeometry-three/src/runtime/runtime-state.ts b/main/opengeometry-three/src/runtime/runtime-state.ts index 24845a7a..670e7d12 100644 --- a/main/opengeometry-three/src/runtime/runtime-state.ts +++ b/main/opengeometry-three/src/runtime/runtime-state.ts @@ -28,6 +28,7 @@ export type Runtime = { graphMoved: boolean; pass: number; displayed: Map; + hostDrawn: WeakMap; pending: Map>; generations: Map; shapeBuckets: Map; diff --git a/main/opengeometry-three/tests/node/host-drawn.test.mjs b/main/opengeometry-three/tests/node/host-drawn.test.mjs new file mode 100644 index 00000000..369f1c12 --- /dev/null +++ b/main/opengeometry-three/tests/node/host-drawn.test.mjs @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import * as THREE from 'three'; +import { OpenGeometry, OG_PRIMITIVE_CUBOID } from '../../../dist/index.js'; +import { runtime } from '../../../dist/testing.js'; +import { boot, cuboid, failsWith, nextTask } from './support.mjs'; + +async function fresh() { + await boot(); + OpenGeometry.reset(); +} + +function current(body) { + return body.record?.shapeId === body.lastInfo.shapeId && body.record?.revision === body.lastInfo.shapeRevision; +} + +function stamped(body) { + const state = runtime(); + return (state.displayed.get(body) ?? -Infinity) >= state.pass - 1; +} + +function renderList(scene) { + const listed = []; + scene.traverseVisible((object) => { listed.push(object); }); + return listed; +} + +function asyncProvider() { + const provider = Object(runtime().provider); + const compute = provider.compute.bind(provider); + provider.compute = undefined; + provider.request = (request) => new Promise((resolve) => { setTimeout(() => { resolve(compute(request)); }, 1); }); + return () => { delete provider.compute; delete provider.request; }; +} + +async function until(check) { + for (let index = 0; index < 10 && !check(); index++) await nextTask(); + return check(); +} + +test('a host-drawn body and everything under it are left out of the render-list walk', async () => { + await fresh(); + const [hosted, kept] = [cuboid('host-walk'), cuboid('host-kept', 2)]; + const scene = new THREE.Scene(); + scene.add(hosted, kept); + try { + OpenGeometry.setHostDrawn([hosted], true); + assert.equal(hosted.visible, false); + const listed = renderList(scene); + assert.equal(listed.includes(hosted.surface) || listed.includes(hosted.outline), false); + assert.equal(listed.includes(kept.surface), true); + await nextTask(); + } finally { + OpenGeometry.reset(); + } +}); + +test('markDisplayed stamps a host-drawn body and it gets its worker geometry', async () => { + await fresh(); + const restore = asyncProvider(); + const body = cuboid('host-marked'); + try { + OpenGeometry.setHostDrawn([body], true); + OpenGeometry.markDisplayed([body]); + assert.equal(stamped(body), true); + assert.equal(await until(() => current(body)), true); + } finally { + restore(); + OpenGeometry.reset(); + } +}); + +test('a host-drawn body swaps to its new record after an edit', async () => { + await fresh(); + const restore = asyncProvider(); + const body = cuboid('host-swap'); + try { + OpenGeometry.setHostDrawn([body], true); + OpenGeometry.markDisplayed([body]); + assert.equal(await until(() => current(body)), true); + body.rebuild(OG_PRIMITIVE_CUBOID, { width: 3, height: 1, depth: 1 }); + OpenGeometry.flush(); + OpenGeometry.markDisplayed([body]); + assert.equal(await until(() => current(body)), true); + assert.equal(body.visible, false); + } finally { + restore(); + OpenGeometry.reset(); + } +}); + +test('a sync flush of given bodies and a walk mesh a host-drawn body', async () => { + await fresh(); + const [flushed, walked] = [cuboid('host-flushed'), cuboid('host-walked', 2)]; + try { + OpenGeometry.setHostDrawn([flushed, walked], true); + OpenGeometry.flush({ geometry: 'sync', bodies: [flushed] }); + assert.equal(current(flushed), true); + walked.updateMatrixWorld(true); + assert.equal(current(walked), true); + await nextTask(); + } finally { + OpenGeometry.reset(); + } +}); + +test('hide and revive while host-drawn keep the logical state and leave three.js visible false', async () => { + await fresh(); + await nextTask(); + const body = cuboid('host-revived'); + const scene = new THREE.Scene(); + scene.add(body); + OpenGeometry.setHostDrawn([body], true); + const mark = OpenGeometry.mark(); + body.dispose(); + assert.equal(body.visible, false); + OpenGeometry.markDisplayed([body]); + assert.equal(runtime().displayed.has(body), false, 'a hidden host-drawn body is not stamped'); + mark.rollback(); + mark.release(); + await nextTask(); + assert.equal(body.parent, scene); + assert.equal(body.visible, false); + OpenGeometry.markDisplayed([body]); + assert.equal(stamped(body), true, 'a revived host-drawn body is stamped'); + OpenGeometry.setHostDrawn([body], false); + assert.equal(body.visible, true); + await nextTask(); +}); + +test('leaving the host draw restores three.js visible to the logical value exactly', async () => { + await fresh(); + const [shown, hidden] = [cuboid('host-shown'), cuboid('host-hidden', 2)]; + hidden.visible = false; + try { + OpenGeometry.setHostDrawn([shown, hidden], true); + OpenGeometry.setHostDrawn([shown, hidden], true); + assert.deepEqual([shown.visible, hidden.visible], [false, false]); + OpenGeometry.markDisplayed([hidden]); + assert.equal(runtime().displayed.has(hidden), false); + OpenGeometry.setHostDrawn([shown, hidden], false); + OpenGeometry.setHostDrawn([shown, hidden], false); + assert.deepEqual([shown.visible, hidden.visible], [true, false]); + await nextTask(); + } finally { + OpenGeometry.reset(); + } +}); + +test('reset while host-drawn leaves three.js visible false', async () => { + await fresh(); + const body = cuboid('host-reset'); + OpenGeometry.setHostDrawn([body], true); + OpenGeometry.reset(); + assert.equal(body.visible, false); +}); + +test('a body never handed to a host keeps its own visible through hide and revive', async () => { + await fresh(); + await nextTask(); + const body = cuboid('never-hosted'); + const scene = new THREE.Scene(); + scene.add(body); + const mark = OpenGeometry.mark(); + body.dispose(); + assert.equal(body.visible, false); + mark.rollback(); + mark.release(); + await nextTask(); + assert.equal(body.visible, true); + OpenGeometry.markDisplayed([body]); + assert.equal(stamped(body), true); + await nextTask(); +}); + +test('setHostDrawn validates bodies as markDisplayed does', async () => { + await fresh(); + const [body, disposed] = [cuboid('host-valid'), cuboid('host-disposed', 2)]; + disposed.dispose(); + try { + const invalid = failsWith('InvalidParameter'); + assert.throws(() => Reflect.apply(OpenGeometry.setHostDrawn, OpenGeometry, [body, true]), invalid); + assert.throws(() => Reflect.apply(OpenGeometry.setHostDrawn, OpenGeometry, [[new THREE.Mesh()], true]), invalid); + assert.throws(() => OpenGeometry.setHostDrawn([disposed], true), failsWith('Disposed')); + assert.equal(body.visible, true); + } finally { + OpenGeometry.reset(); + } +}); + +test('setHostDrawn false ignores a disposed body and true still throws Disposed', async () => { + await fresh(); + const [disposed, kept] = [cuboid('host-release-disposed'), cuboid('host-release-kept', 2)]; + try { + OpenGeometry.setHostDrawn([disposed, kept], true); + disposed.dispose(); + const visible = disposed.visible; + OpenGeometry.setHostDrawn([disposed, kept], false); + assert.equal(disposed.visible, visible); + assert.equal(kept.visible, true); + assert.throws(() => OpenGeometry.setHostDrawn([disposed], true), failsWith('Disposed')); + assert.equal(disposed.visible, visible); + } finally { + OpenGeometry.reset(); + } +}); + +test('setHostDrawn false ignores a body the kernel finalized during a render pass', async () => { + await fresh(); + const [drawn, finalized] = [cuboid('host-pass-drawn'), cuboid('host-pass-finalized', 2)]; + const kept = cuboid('host-pass-kept', 3); + const scene = new THREE.Scene(); + scene.add(drawn, finalized, kept); + try { + OpenGeometry.setHostDrawn([finalized, kept], true); + OpenGeometry.markDisplayed([drawn]); + assert.equal(runtime().renderPassActive, true); + finalized.dispose(); + assert.equal([...runtime().bodies.values()].includes(finalized), false); + OpenGeometry.setHostDrawn([finalized, kept], false); + assert.equal(finalized.visible, false); + assert.equal(kept.visible, true); + assert.throws(() => OpenGeometry.setHostDrawn([finalized], true), failsWith('Disposed')); + await nextTask(); + assert.equal(finalized.parent, null); + } finally { + OpenGeometry.reset(); + } +}); From 190da4928ced639756eb8852252de5f46e90762d Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Wed, 4 Nov 2026 22:02:35 +0100 Subject: [PATCH 7/9] perf(world-graph): look a node up by handle through an index (K-05) WorldGraph keeps a (handle, generation) to og_id index, written by commit and the journal unwind only, so node_by_handle is a lookup with the same result and errors as the scan it replaces. Signed-off-by: Vishwajeet Mane --- main/opengeometry/src/world_graph/commit.rs | 8 +- main/opengeometry/src/world_graph/graph.rs | 18 +- main/opengeometry/src/world_graph/journal.rs | 8 +- main/opengeometry/src/world_graph/mod.rs | 2 + main/opengeometry/src/world_graph/queries.rs | 6 +- .../src/world_graph/tests/handle_index.rs | 126 +++++++++++++ .../src/world_graph/tests/handle_oracle.rs | 169 ++++++++++++++++++ .../opengeometry/src/world_graph/tests/mod.rs | 3 + .../src/world_graph/tests/support.rs | 67 +++++++ 9 files changed, 391 insertions(+), 16 deletions(-) create mode 100644 main/opengeometry/src/world_graph/tests/handle_index.rs create mode 100644 main/opengeometry/src/world_graph/tests/handle_oracle.rs create mode 100644 main/opengeometry/src/world_graph/tests/mod.rs create mode 100644 main/opengeometry/src/world_graph/tests/support.rs diff --git a/main/opengeometry/src/world_graph/commit.rs b/main/opengeometry/src/world_graph/commit.rs index 6bb413b9..2f6c6167 100644 --- a/main/opengeometry/src/world_graph/commit.rs +++ b/main/opengeometry/src/world_graph/commit.rs @@ -133,12 +133,8 @@ impl WorldGraph { } for (id, edit) in edits.nodes { match edit { - Some(node) => { - self.nodes.insert(id, node); - } - None => { - self.nodes.remove(&id); - } + Some(node) => self.insert_node(id, node), + None => self.remove_node(&id), } } self.shapes.next_id = edits.next_shape_id; diff --git a/main/opengeometry/src/world_graph/graph.rs b/main/opengeometry/src/world_graph/graph.rs index ca985ec8..01bf7ec5 100644 --- a/main/opengeometry/src/world_graph/graph.rs +++ b/main/opengeometry/src/world_graph/graph.rs @@ -5,7 +5,7 @@ use super::node::{MarkId, Node}; use super::shape_store::{Shape, ShapeId, ShapeStore}; use crate::brep::{Accuracy, Similarity3}; use std::cell::RefCell; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; pub(super) struct JournalEntry { pub(super) nodes: BTreeMap>, @@ -21,6 +21,7 @@ pub struct WorldGraph { pub(super) accuracy: Accuracy, pub(super) shapes: ShapeStore, pub(super) nodes: BTreeMap, + pub(super) handles: HashMap<(u32, u32), String>, pub(super) revision: u64, pub(super) changes: Vec, pub(super) journal: Vec, @@ -40,6 +41,7 @@ impl WorldGraph { accuracy, shapes: ShapeStore::default(), nodes: BTreeMap::new(), + handles: HashMap::new(), revision: 0, changes: Vec::new(), journal: Vec::new(), @@ -67,6 +69,20 @@ impl WorldGraph { pub fn node_count(&self) -> usize { self.nodes.len() } + + pub(super) fn insert_node(&mut self, og_id: String, node: Node) { + let key = (node.handle, node.generation); + if let Some(previous) = self.nodes.insert(og_id.clone(), node) { + self.handles.remove(&(previous.handle, previous.generation)); + } + self.handles.insert(key, og_id); + } + + pub(super) fn remove_node(&mut self, og_id: &str) { + if let Some(previous) = self.nodes.remove(og_id) { + self.handles.remove(&(previous.handle, previous.generation)); + } + } } fn check_standard(accuracy: Accuracy) -> Result<(), GraphError> { diff --git a/main/opengeometry/src/world_graph/journal.rs b/main/opengeometry/src/world_graph/journal.rs index 25ba0d33..21f8be90 100644 --- a/main/opengeometry/src/world_graph/journal.rs +++ b/main/opengeometry/src/world_graph/journal.rs @@ -95,12 +95,8 @@ impl WorldGraph { })?; for (id, before) in entry.nodes { match before { - Some(node) => { - self.nodes.insert(id, node); - } - None => { - self.nodes.remove(&id); - } + Some(node) => self.insert_node(id, node), + None => self.remove_node(&id), } } for (id, before) in entry.shapes { diff --git a/main/opengeometry/src/world_graph/mod.rs b/main/opengeometry/src/world_graph/mod.rs index 1dadc414..e79be86d 100644 --- a/main/opengeometry/src/world_graph/mod.rs +++ b/main/opengeometry/src/world_graph/mod.rs @@ -21,6 +21,8 @@ mod primitive; mod queries; mod shape_store; mod shelling; +#[cfg(test)] +mod tests; mod tree_walk; pub use change_log::{ChangeSet, NodeChange}; diff --git a/main/opengeometry/src/world_graph/queries.rs b/main/opengeometry/src/world_graph/queries.rs index d9314f0e..938a1ff6 100644 --- a/main/opengeometry/src/world_graph/queries.rs +++ b/main/opengeometry/src/world_graph/queries.rs @@ -140,9 +140,9 @@ impl WorldGraph { } pub fn node_by_handle(&self, handle: u32, generation: u32) -> Result<&Node, GraphError> { - self.nodes - .values() - .find(|node| node.handle == handle && node.generation == generation) + self.handles + .get(&(handle, generation)) + .and_then(|og_id| self.nodes.get(og_id)) .ok_or_else(|| GraphError::code(ErrorCode::Disposed, "node handle is stale")) } } diff --git a/main/opengeometry/src/world_graph/tests/handle_index.rs b/main/opengeometry/src/world_graph/tests/handle_index.rs new file mode 100644 index 00000000..2e1bd3aa --- /dev/null +++ b/main/opengeometry/src/world_graph/tests/handle_index.rs @@ -0,0 +1,126 @@ +use super::support::{child_of, copy_named, cuboid, found, graph, key, named, stale}; +use crate::world_graph::{CopyOptions, EditScope, Primitive}; + +#[test] +fn lookup_finds_each_created_body_and_assembly() { + let mut world = graph(); + world.create_system_assembly(named("level")).unwrap(); + world + .create_primitive(cuboid(), child_of("wall", "level")) + .unwrap(); + world.create_primitive(cuboid(), named("slab")).unwrap(); + for og_id in ["level", "wall", "slab"] { + assert_eq!(found(&world, key(&world, og_id)), og_id); + } +} + +#[test] +fn lookup_refuses_a_handle_from_a_fresh_graph() { + let mut world = graph(); + world.create_primitive(cuboid(), named("body")).unwrap(); + let issued = key(&world, "body"); + let fresh = graph(); + stale(&fresh, issued); + assert_eq!(found(&world, issued), "body"); +} + +#[test] +fn lookup_refuses_a_stale_generation_and_an_unissued_handle() { + let mut world = graph(); + world.create_primitive(cuboid(), named("body")).unwrap(); + let (handle, generation) = key(&world, "body"); + stale(&world, (handle, generation + 1)); + stale(&world, (handle + 1, generation)); + stale(&world, (u32::MAX, u32::MAX)); +} + +#[test] +fn lookup_refuses_a_disposed_node_and_its_descendants() { + let mut world = graph(); + world.create_system_assembly(named("level")).unwrap(); + world + .create_primitive(cuboid(), child_of("wall", "level")) + .unwrap(); + world.create_primitive(cuboid(), named("slab")).unwrap(); + let level = key(&world, "level"); + let wall = key(&world, "wall"); + world.dispose("level").unwrap(); + stale(&world, level); + stale(&world, wall); + assert_eq!(found(&world, key(&world, "slab")), "slab"); +} + +#[test] +fn lookup_keeps_a_handle_across_reparenting() { + let mut world = graph(); + world.create_system_assembly(named("first")).unwrap(); + world.create_system_assembly(named("second")).unwrap(); + world + .create_primitive(cuboid(), child_of("wall", "first")) + .unwrap(); + let wall = key(&world, "wall"); + world.add_child("second", &["wall".into()], true).unwrap(); + assert_eq!(found(&world, wall), "wall"); + world.remove_child("second", "wall", false).unwrap(); + assert_eq!(found(&world, wall), "wall"); + assert_eq!(key(&world, "wall"), wall); +} + +#[test] +fn lookup_finds_instances_and_duplicates_under_their_own_handles() { + let mut world = graph(); + world.create_primitive(cuboid(), named("body")).unwrap(); + world.instance("body", copy_named("instance")).unwrap(); + world.duplicate("body", copy_named("copy")).unwrap(); + let (unnamed, _) = world.instance("copy", CopyOptions::default()).unwrap(); + let keys: Vec<_> = ["body", "instance", "copy", unnamed.as_str()] + .iter() + .map(|og_id| key(&world, og_id)) + .collect(); + for (index, og_id) in ["body", "instance", "copy", unnamed.as_str()] + .iter() + .enumerate() + { + assert_eq!(found(&world, keys[index]), *og_id); + } + world.make_unique("instance").unwrap().unwrap(); + assert_eq!(found(&world, keys[1]), "instance"); + world.dispose("body").unwrap(); + stale(&world, keys[0]); + assert_eq!(found(&world, keys[1]), "instance"); +} + +#[test] +fn lookup_keeps_a_handle_across_a_rebuild() { + let mut world = graph(); + world.create_primitive(cuboid(), named("body")).unwrap(); + let body = key(&world, "body"); + let sphere = Primitive::Sphere { radius: 1.0 }; + world + .rebuild_primitive("body", sphere, EditScope::Node) + .unwrap(); + assert_eq!(found(&world, body), "body"); +} + +#[test] +fn lookup_after_rollback_restores_disposed_nodes_and_drops_created_ones() { + let mut world = graph(); + world.create_system_assembly(named("level")).unwrap(); + world + .create_primitive(cuboid(), child_of("wall", "level")) + .unwrap(); + let level = key(&world, "level"); + let wall = key(&world, "wall"); + let mark = world.mark().unwrap(); + world.dispose("level").unwrap(); + world.create_primitive(cuboid(), named("wall")).unwrap(); + let recreated = key(&world, "wall"); + assert_ne!(recreated, wall); + stale(&world, wall); + assert_eq!(found(&world, recreated), "wall"); + world.rollback(mark).unwrap(); + assert_eq!(found(&world, level), "level"); + assert_eq!(found(&world, wall), "wall"); + stale(&world, recreated); + world.release(mark).unwrap(); +} diff --git a/main/opengeometry/src/world_graph/tests/handle_oracle.rs b/main/opengeometry/src/world_graph/tests/handle_oracle.rs new file mode 100644 index 00000000..9d4f5b9a --- /dev/null +++ b/main/opengeometry/src/world_graph/tests/handle_oracle.rs @@ -0,0 +1,169 @@ +use super::support::{agrees, cuboid, graph}; +use crate::world_graph::node::NodeKind; +use crate::world_graph::{ChangeSet, CopyOptions, CreateOptions, WorldGraph}; + +struct Random(u64); + +impl Random { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, bound: usize) -> usize { + (self.next() % bound as u64) as usize + } +} + +struct Run { + world: WorldGraph, + random: Random, + assemblies: Vec, + bodies: Vec, + issued: Vec<(u32, u32)>, +} + +impl Run { + fn record(&mut self, changes: ChangeSet) { + self.issued.extend( + changes + .added + .iter() + .map(|change| (change.handle, change.generation)), + ); + } + + fn pick(&mut self, assembly: bool) -> Option { + loop { + let pool = if assembly { + &mut self.assemblies + } else { + &mut self.bodies + }; + if pool.is_empty() { + return None; + } + let index = self.random.below(pool.len()); + if self.world.node(&pool[index]).is_ok() { + return Some(pool[index].clone()); + } + pool.swap_remove(index); + } + } + + fn refresh(&mut self) { + let nodes = self.world.nodes.values(); + let (assemblies, bodies): (Vec<_>, Vec<_>) = + nodes.partition(|node| node.kind == NodeKind::SystemAssembly); + self.assemblies = assemblies.iter().map(|node| node.og_id.clone()).collect(); + self.bodies = bodies.iter().map(|node| node.og_id.clone()).collect(); + } + + fn step(&mut self) { + let roll = self.random.below(100); + let parent = self.pick(true).filter(|_| roll % 2 == 0); + let body = self.pick(false); + match (roll, body) { + (0..=14, _) | (_, None) => { + let options = CreateOptions { + parent, + ..CreateOptions::default() + }; + let (id, changes) = if roll < 10 { + self.world.create_system_assembly(options).unwrap() + } else { + self.world.create_primitive(cuboid(), options).unwrap() + }; + if roll < 10 { + self.assemblies.push(id); + } else { + self.bodies.push(id); + } + self.record(changes); + } + (15..=74, Some(body)) => { + let (id, changes) = self.world.instance(&body, copy_into(parent)).unwrap(); + self.bodies.push(id); + self.record(changes); + } + (75..=84, Some(body)) => { + let (id, changes) = self.world.duplicate(&body, copy_into(parent)).unwrap(); + self.bodies.push(id); + self.record(changes); + } + (85..=89, Some(body)) => { + self.world.dispose(&body).unwrap(); + } + (90..=95, Some(body)) => match parent { + Some(parent) => { + self.world + .add_child(&parent, &[body], roll % 4 == 0) + .unwrap(); + } + None => { + if let Some(parent) = self.world.parent(&body).unwrap().map(str::to_string) { + self.world.remove_child(&parent, &body, false).unwrap(); + } + } + }, + (_, Some(body)) => { + self.world.make_unique(&body).unwrap(); + } + } + } + + fn check_sample(&mut self, count: usize) { + assert_eq!(self.world.handles.len(), self.world.nodes.len()); + for _ in 0..count { + let (handle, generation) = self.issued[self.random.below(self.issued.len())]; + agrees(&self.world, handle, generation); + } + } +} + +fn copy_into(parent: Option) -> CopyOptions { + CopyOptions { + og_id: None, + parent: parent.map(Some), + } +} + +#[test] +fn indexed_lookup_matches_a_scan_on_a_random_graph_of_ten_thousand_nodes() { + let mut run = Run { + world: graph(), + random: Random(0x9e37_79b9_7f4a_7c15), + assemblies: Vec::new(), + bodies: Vec::new(), + issued: Vec::new(), + }; + let mut steps = 0; + while run.world.node_count() < 10_000 { + if steps == 4_000 { + let mark = run.world.mark().unwrap(); + for _ in 0..600 { + run.step(); + } + run.check_sample(200); + run.world.rollback(mark).unwrap(); + run.world.release(mark).unwrap(); + run.refresh(); + } + run.step(); + steps += 1; + if steps % 1_000 == 0 { + run.check_sample(200); + } + } + let issued = run.issued.clone(); + for (handle, generation) in issued { + agrees(&run.world, handle, generation); + agrees(&run.world, handle, generation.wrapping_add(1)); + } + let live = run.world.nodes.values().count(); + assert_eq!(run.world.handles.len(), live); + assert!(live >= 10_000); + assert!(run.issued.len() > live); +} diff --git a/main/opengeometry/src/world_graph/tests/mod.rs b/main/opengeometry/src/world_graph/tests/mod.rs new file mode 100644 index 00000000..b16ab584 --- /dev/null +++ b/main/opengeometry/src/world_graph/tests/mod.rs @@ -0,0 +1,3 @@ +mod handle_index; +mod handle_oracle; +mod support; diff --git a/main/opengeometry/src/world_graph/tests/support.rs b/main/opengeometry/src/world_graph/tests/support.rs new file mode 100644 index 00000000..1ecfc1df --- /dev/null +++ b/main/opengeometry/src/world_graph/tests/support.rs @@ -0,0 +1,67 @@ +use crate::brep::Accuracy; +use crate::world_graph::{CopyOptions, CreateOptions, ErrorCode, Primitive, WorldGraph}; + +pub(super) fn graph() -> WorldGraph { + WorldGraph::new(Accuracy::STANDARD).unwrap() +} + +pub(super) fn named(id: &str) -> CreateOptions { + CreateOptions { + og_id: Some(id.into()), + ..CreateOptions::default() + } +} + +pub(super) fn child_of(id: &str, parent: &str) -> CreateOptions { + CreateOptions { + og_id: Some(id.into()), + parent: Some(parent.into()), + ..CreateOptions::default() + } +} + +pub(super) fn copy_named(id: &str) -> CopyOptions { + CopyOptions { + og_id: Some(id.into()), + ..CopyOptions::default() + } +} + +pub(super) fn cuboid() -> Primitive { + Primitive::Cuboid { + width: 1.0, + height: 2.0, + depth: 3.0, + } +} + +pub(super) fn key(world: &WorldGraph, og_id: &str) -> (u32, u32) { + let node = world.node(og_id).unwrap(); + (node.handle, node.generation) +} + +pub(super) fn found(world: &WorldGraph, key: (u32, u32)) -> String { + world.node_by_handle(key.0, key.1).unwrap().og_id.clone() +} + +pub(super) fn stale(world: &WorldGraph, key: (u32, u32)) { + let error = world.node_by_handle(key.0, key.1).unwrap_err(); + assert_eq!(error.error_code(), ErrorCode::Disposed); + assert_eq!(error.message(), "node handle is stale"); +} + +pub(super) fn scan(world: &WorldGraph, handle: u32, generation: u32) -> Option { + world + .nodes + .values() + .find(|node| node.handle == handle && node.generation == generation) + .map(|node| node.og_id.clone()) +} + +pub(super) fn agrees(world: &WorldGraph, handle: u32, generation: u32) { + let indexed = world.node_by_handle(handle, generation); + match scan(world, handle, generation) { + Some(og_id) => assert_eq!(indexed.unwrap().og_id, og_id), + None => stale(world, (handle, generation)), + } +} From c139d230df39722fb9977972e3ba6dba9db5d623 Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Wed, 4 Nov 2026 23:20:41 +0100 Subject: [PATCH 8/9] perf(render): stamp a host layer's bodies through one host group per pass (K-08) OpenGeometry.hostGroup(bodies) validates its members once; group.drawn() stamps them for the pass in constant time, and installs run only for bodies with pending work; markDisplayed is unchanged. Signed-off-by: Vishwajeet Mane --- README.md | 2 +- main/opengeometry-three/index.ts | 1 + main/opengeometry-three/src/opengeometry.ts | 2 + .../rendering/geometry/geometry-scheduler.ts | 2 + .../src/rendering/geometry/host-group.ts | 40 +++ .../src/rendering/geometry/render-pass.ts | 17 +- .../src/rendering/lod/host-groups.ts | 91 ++++++ .../src/rendering/lod/lod-controller.ts | 17 +- .../src/runtime/create-runtime.ts | 3 +- .../src/runtime/runtime-state.ts | 2 + .../src/testing/internals.ts | 5 +- .../tests/node/host-groups.test.mjs | 269 ++++++++++++++++++ 12 files changed, 437 insertions(+), 14 deletions(-) create mode 100644 main/opengeometry-three/src/rendering/geometry/host-group.ts create mode 100644 main/opengeometry-three/src/rendering/lod/host-groups.ts create mode 100644 main/opengeometry-three/tests/node/host-groups.test.mjs diff --git a/README.md b/README.md index b6f30b73..39ce4912 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)`; `OpenGeometry.observeCamera(renderer, camera)` observes the first camera seen per renderer frame (`renderer.info.render.frame`) for level of detail; a `render()` nested inside an `onBeforeRender` (Reflector style) starts a new frame, and an `ArrayCamera` is observed through its first sub-camera (a host whose drawables are not kernel bodies, such as instanced or batched layers, calls it from their `onBeforeRender`); `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`; `OpenGeometry.markDisplayed(bodies)` counts those bodies as displayed for the current render pass (a host that draws bodies through its own instanced or batched layers calls it from that layer's `onBeforeRender`; it works for a body hidden by layer, skips a body with `visible = false`, and validates `bodies` as `flush({ bodies })` does); `OpenGeometry.setHostDrawn(bodies, hostDrawn)` hands the drawing of those bodies to the host (`true`) or gives it back (`false`): while host-drawn, a body's `visible` is `false`, so three.js skips it and everything under it, and the kernel keeps the body's own visibility apart, so `markDisplayed`, a walk, `flush({ geometry: 'sync', bodies })`, level of detail and record swaps treat it as shown, and a dispose, a rollback or a reset changes that visibility instead of `visible`; giving it back sets `visible` to that visibility (a host calls it when a body joins or leaves its instanced or batched layers; do not set `visible` on a host-drawn body; it validates `bodies` as `flush({ bodies })` does, except that giving bodies back ignores a disposed or finalized body, and a `hostDrawn` that is not a boolean throws `InvalidParameter`). A body is displayed when it is drawn (its own `onBeforeRender` or `markDisplayed`) or walked by `updateMatrixWorld`; a draw installs new display geometry after the render, a walk at once. Each body dispatches `geometrychanged` after it installs new display geometry and `placementchanged` when a flush moves it (`BodyEventMap`); their listeners run synchronously, after the whole flush is applied or at the end of the render pass, and a listener that throws is reported through `OpenGeometry.on('error', …)` with the thrown value while the flush and the pass complete. | +| 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.observeCamera(renderer, camera)` observes the first camera seen per renderer frame (`renderer.info.render.frame`) for level of detail; a `render()` nested inside an `onBeforeRender` (Reflector style) starts a new frame, and an `ArrayCamera` is observed through its first sub-camera (a host whose drawables are not kernel bodies, such as instanced or batched layers, calls it from their `onBeforeRender`); `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`; `OpenGeometry.markDisplayed(bodies)` counts those bodies as displayed for the current render pass (a host that draws bodies through its own instanced or batched layers calls it from that layer's `onBeforeRender`; it works for a body hidden by layer, skips a body with `visible = false`, and validates `bodies` as `flush({ bodies })` does); `OpenGeometry.setHostDrawn(bodies, hostDrawn)` hands the drawing of those bodies to the host (`true`) or gives it back (`false`): while host-drawn, a body's `visible` is `false`, so three.js skips it and everything under it, and the kernel keeps the body's own visibility apart, so `markDisplayed`, a walk, `flush({ geometry: 'sync', bodies })`, level of detail and record swaps treat it as shown, and a dispose, a rollback or a reset changes that visibility instead of `visible`; giving it back sets `visible` to that visibility (a host calls it when a body joins or leaves its instanced or batched layers; do not set `visible` on a host-drawn body; it validates `bodies` as `flush({ bodies })` does, except that giving bodies back ignores a disposed or finalized body, and a `hostDrawn` that is not a boolean throws `InvalidParameter`); `OpenGeometry.hostGroup(bodies)` returns a `HostGroup` that stamps all its bodies at once: `group.drawn()` counts every member as displayed for the current render pass exactly as `markDisplayed` does, at a cost that does not grow with the number of members (a host calls it from each instanced or batched layer's `onBeforeRender` in place of `markDisplayed`), `group.set(bodies)` replaces the members and `group.dispose()` ends the group, after which `drawn()` does nothing and its members no longer count through it; a body may be in several groups, a hidden or host-drawn member keeps its own visibility, a member disposed later is skipped and dropped, `hostGroup` and `set` validate `bodies` once as `markDisplayed` does, and `set` on a disposed group or one from a reset runtime throws `Disposed`. A body is displayed when it is drawn (its own `onBeforeRender` or `markDisplayed`) or walked by `updateMatrixWorld`; a draw installs new display geometry after the render, a walk at once. Each body dispatches `geometrychanged` after it installs new display geometry and `placementchanged` when a flush moves it (`BodyEventMap`); their listeners run synchronously, after the whole flush is applied or at the end of the render pass, and a listener that throws is reported through `OpenGeometry.on('error', …)` with the thrown value while the flush and the pass complete. | | 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/index.ts b/main/opengeometry-three/index.ts index 8e097a64..62ea1cbf 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -1,5 +1,6 @@ export { OpenGeometry } from './src/opengeometry'; export { OGMark } from './src/marks/og-mark'; +export type { HostGroup } from './src/rendering/geometry/host-group'; export { SystemAssembly } from './src/bodies/system-assembly'; export { Wire } from './src/bodies/wire'; export { Solid } from './src/bodies/solid'; diff --git a/main/opengeometry-three/src/opengeometry.ts b/main/opengeometry-three/src/opengeometry.ts index a6f7fe58..9d527cbe 100644 --- a/main/opengeometry-three/src/opengeometry.ts +++ b/main/opengeometry-three/src/opengeometry.ts @@ -17,6 +17,7 @@ import { offsetPolyline, offsetPolylineRegions, offsetRingVariable } from './ope import { booleanCurvedRegions2D, booleanRegions2D, insetCurvedRegion2D } from './operations/regions'; import { resolveHit } from './picking/resolve-hit'; import { flush } from './rendering/geometry/geometry-scheduler'; +import { HostGroup } from './rendering/geometry/host-group'; import { markDisplayed, setHostDrawn } from './rendering/geometry/render-pass'; import { settled } from './rendering/geometry/settle'; import { observeCamera } from './rendering/lod/camera-observer'; @@ -40,6 +41,7 @@ export class OpenGeometry { static flush(options: { geometry?: 'sync'; bodies?: readonly Body[] } = {}): void { flush(options); } static markDisplayed(bodies: readonly Body[]): void { markDisplayed(bodies); } static setHostDrawn(bodies: readonly Body[], hostDrawn: boolean): void { setHostDrawn(bodies, hostDrawn); } + static hostGroup(bodies: readonly Body[]): HostGroup { return new HostGroup(bodies); } static settled(options: { deflection?: number } = {}): Promise<{ failed: { ogId: string; error: unknown }[] }> { return settled(options); } diff --git a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts index 80295942..74fde720 100644 --- a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts +++ b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts @@ -9,6 +9,7 @@ import { emit } from '../../runtime/event-bus'; import { bodyKey, currentRuntime, reindexShape, runtime, type Runtime } from '../../runtime/runtime-state'; import { decodeChangeSet } from '../../world-graph/codec'; import { shown } from '../lod/host-drawn'; +import { queueGrouped } from '../lod/host-groups'; import { clearError, displayedBodies, isDisplayed, markFailed, recordError, retryBucket, shapePriority, stampDisplayed, wantedKey, @@ -113,6 +114,7 @@ function applyChanges(state: Runtime, changes: ChangeSet, matrices: Float64Array const matrix = matrices.subarray(offset, offset + 16); if (body.matrixWorld.elements.some((value, index) => value !== matrix[index])) moved.push(body); body.applyWorldMatrix(matrix); + queueGrouped(state, body); } offset += 16; } diff --git a/main/opengeometry-three/src/rendering/geometry/host-group.ts b/main/opengeometry-three/src/rendering/geometry/host-group.ts new file mode 100644 index 00000000..031b8236 --- /dev/null +++ b/main/opengeometry-three/src/rendering/geometry/host-group.ts @@ -0,0 +1,40 @@ +import type { Body } from '../../bodies/body'; +import { OGError } from '../../errors'; +import { currentRuntime, runtime, type Runtime } from '../../runtime/runtime-state'; +import { dropGroup, setMembers, stampGroup, type GroupState } from '../lod/host-groups'; +import { checkedBodies } from './geometry-scheduler'; +import { beginPass } from './render-pass'; + +function members(state: Runtime, bodies: readonly Body[], label: string): Body[] { + return [...new Set(checkedBodies(state, bodies, label))]; +} + +export class HostGroup { + private readonly state: Runtime = runtime(); + private readonly group: GroupState = { pass: -Infinity, members: [] }; + private disposed = false; + + constructor(bodies: readonly Body[]) { + setMembers(this.state, this.group, members(this.state, bodies, 'OpenGeometry.hostGroup')); + this.state.groups.live.add(this.group); + } + + drawn(): void { + if (this.disposed || currentRuntime() !== this.state) return; + beginPass(this.state); + stampGroup(this.state, this.group); + } + + set(bodies: readonly Body[]): void { + if (this.disposed || currentRuntime() !== this.state) { + throw new OGError('Disposed', 'HostGroup.set', 'the host group is disposed'); + } + setMembers(this.state, this.group, members(this.state, bodies, 'HostGroup.set')); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (currentRuntime() === this.state) dropGroup(this.state, this.group); + } +} diff --git a/main/opengeometry-three/src/rendering/geometry/render-pass.ts b/main/opengeometry-three/src/rendering/geometry/render-pass.ts index bffc4d7c..4eacc113 100644 --- a/main/opengeometry-three/src/rendering/geometry/render-pass.ts +++ b/main/opengeometry-three/src/rendering/geometry/render-pass.ts @@ -2,7 +2,8 @@ import type { Body } from '../../bodies/body'; import { OGError } from '../../errors'; import { currentRuntime, runtime, type Runtime } from '../../runtime/runtime-state'; import { handToHost, shown } from '../lod/host-drawn'; -import { displayedBodies, evaluatePass, isDisplayed, stampDisplayed } from '../lod/lod-controller'; +import { takeWaiting } from '../lod/host-groups'; +import { displayedBodies, evaluatePass, isDisplayed, stampDisplayed, wakeDisplayed } from '../lod/lod-controller'; import { checkedBodies, ensureGeometry, flush } from './geometry-scheduler'; export function noteDisplayed(body: Body): void { @@ -36,7 +37,7 @@ export function setHostDrawn(bodies: readonly Body[], hostDrawn: boolean): void for (const body of checkedBodies(state, bodies, label, !hostDrawn)) handToHost(state, body, hostDrawn); } -function beginPass(state: Runtime): boolean { +export function beginPass(state: Runtime): boolean { if (state.renderPassActive) return false; state.renderPassActive = true; queueMicrotask(() => { endPass(state); }); @@ -45,7 +46,7 @@ function beginPass(state: Runtime): boolean { function endPass(state: Runtime): void { if (currentRuntime() !== state) return; - const drawn = [...state.lod.drawn]; + const drawn = new Set(state.lod.drawn); state.lod.drawn.clear(); try { installDrawn(state, drawn); @@ -57,9 +58,15 @@ function endPass(state: Runtime): void { if (evaluatePass(state, Date.now(), false)) refreshDisplayed(state); } -function installDrawn(state: Runtime, drawn: readonly Body[]): void { - if (drawn.length === 0) return; +function installDrawn(state: Runtime, drawn: ReadonlySet): void { + const groups = state.groups; + if (drawn.size === 0 && !groups.drawn && groups.pending.size === 0 && groups.woken.size === 0) return; flush(); + for (const body of takeWaiting(state)) { + if (drawn.has(body) || !isDisplayed(state, body)) continue; + wakeDisplayed(state, body); + ensureGeometry(body); + } for (const body of drawn) if (isDisplayed(state, body)) ensureGeometry(body); } diff --git a/main/opengeometry-three/src/rendering/lod/host-groups.ts b/main/opengeometry-three/src/rendering/lod/host-groups.ts new file mode 100644 index 00000000..f1aec3e7 --- /dev/null +++ b/main/opengeometry-three/src/rendering/lod/host-groups.ts @@ -0,0 +1,91 @@ +import type { Body } from '../../bodies/body'; +import { bodyKey, type Runtime } from '../../runtime/runtime-state'; + +export type GroupState = { pass: number; members: readonly Body[] }; + +export type HostGroups = { + live: Set; + of: WeakMap; + woken: Set; + pending: Set; + drawn: boolean; +}; + +export function createHostGroups(): HostGroups { + return { live: new Set(), of: new WeakMap(), woken: new Set(), pending: new Set(), drawn: false }; +} + +export function groupShown(state: Runtime, group: GroupState): boolean { + return group.pass >= state.pass - 1; +} + +export function groupDisplayed(state: Runtime, body: Body): boolean { + const groups = state.groups.of.get(body); + if (!groups) return false; + for (const group of groups) if (groupShown(state, group)) return true; + return false; +} + +export function stampGroup(state: Runtime, group: GroupState): void { + if (!groupShown(state, group)) { + state.lod.stale = true; + state.groups.woken.add(group); + } + state.groups.drawn = true; + group.pass = state.pass; +} + +export function setMembers(state: Runtime, group: GroupState, members: readonly Body[]): void { + for (const body of group.members) leave(state, body, group); + group.members = members; + for (const body of members) join(state, body, group); + state.lod.stale = true; + if (groupShown(state, group)) state.groups.woken.add(group); +} + +export function dropGroup(state: Runtime, group: GroupState): void { + setMembers(state, group, []); + group.pass = -Infinity; + state.groups.live.delete(group); + state.groups.woken.delete(group); +} + +export function liveMembers(state: Runtime, group: GroupState): readonly Body[] { + const live = group.members.filter((body) => state.bodies.get(bodyKey(body.handle, body.generation)) === body); + if (live.length !== group.members.length) group.members = live; + return live; +} + +export function groupedBodies(state: Runtime): Body[] { + const bodies: Body[] = []; + for (const group of state.groups.live) { + if (groupShown(state, group)) for (const body of liveMembers(state, group)) bodies.push(body); + } + return bodies; +} + +export function queueGrouped(state: Runtime, body: Body): void { + if (state.groups.of.get(body)?.length) state.groups.pending.add(body); +} + +export function takeWaiting(state: Runtime): Set { + const groups = state.groups; + const waiting = new Set(groups.pending); + for (const group of groups.woken) for (const body of liveMembers(state, group)) waiting.add(body); + groups.pending.clear(); + groups.woken.clear(); + groups.drawn = false; + return waiting; +} + +function join(state: Runtime, body: Body, group: GroupState): void { + const groups = state.groups.of.get(body); + if (groups) groups.push(group); + else state.groups.of.set(body, [group]); +} + +function leave(state: Runtime, body: Body, group: GroupState): void { + const groups = state.groups.of.get(body) ?? []; + const index = groups.indexOf(group); + if (index >= 0) groups.splice(index, 1); +} diff --git a/main/opengeometry-three/src/rendering/lod/lod-controller.ts b/main/opengeometry-three/src/rendering/lod/lod-controller.ts index dbd38944..cb008363 100644 --- a/main/opengeometry-three/src/rendering/lod/lod-controller.ts +++ b/main/opengeometry-three/src/rendering/lod/lod-controller.ts @@ -6,6 +6,7 @@ import { currentRuntime, type Runtime } from '../../runtime/runtime-state'; import { finestLocal, localTarget, shapeBuckets, type ShapeBuckets } from './body-size'; import { deflectionBucket, wantedBucket } from './deflection'; import { displayBounds } from './display-bounds'; +import { groupDisplayed, groupedBodies } from './host-groups'; import { shown } from './host-drawn'; import { worldUnitsPerPixel } from './screen-metrics'; @@ -53,21 +54,25 @@ export function createLodState(refresh: () => void): LodState { } export function isDisplayed(state: Runtime, body: Body): boolean { - return shown(state, body) && (state.displayed.get(body) ?? -Infinity) >= state.pass - 1; + if (!shown(state, body)) return false; + return (state.displayed.get(body) ?? -Infinity) >= state.pass - 1 || groupDisplayed(state, body); } export function displayedBodies(state: Runtime): Body[] { - return [...state.displayed.keys()].filter((body) => isDisplayed(state, body)); + return [...new Set([...state.displayed.keys(), ...groupedBodies(state)])].filter((body) => isDisplayed(state, body)); } export function stampDisplayed(state: Runtime, body: Body): void { - const shapeId = body.lastInfo.shapeId; - const anew = !isDisplayed(state, body); - if (anew) state.lod.stale = true; - if (shapeId && anew) delete state.lod.shapes.get(shapeId)?.overrides; + if (!isDisplayed(state, body)) wakeDisplayed(state, body); state.displayed.set(body, state.pass); } +export function wakeDisplayed(state: Runtime, body: Body): void { + const shapeId = body.lastInfo.shapeId; + state.lod.stale = true; + if (shapeId) delete state.lod.shapes.get(shapeId)?.overrides; +} + export function observe(state: Runtime, camera: THREE.Camera, height: number, now: number): void { const lod = state.lod; const moved = storeChanged(lod.camera, camera.matrixWorld.elements, 0); diff --git a/main/opengeometry-three/src/runtime/create-runtime.ts b/main/opengeometry-three/src/runtime/create-runtime.ts index 693be4e8..7fc9332c 100644 --- a/main/opengeometry-three/src/runtime/create-runtime.ts +++ b/main/opengeometry-three/src/runtime/create-runtime.ts @@ -4,6 +4,7 @@ import { compileKernel, initKernel, OGWorldGraph } from '../kernel/kernel-loader import { ACCURACY } from '../limits'; import { refreshDisplayed } from '../rendering/geometry/render-pass'; import { InlineBackend } from '../rendering/inline-backend'; +import { createHostGroups } from '../rendering/lod/host-groups'; import { createLodState } from '../rendering/lod/lod-controller'; import { RecordPool } from '../rendering/records/record-pool'; import { WorkerBackend } from '../rendering/worker-backend'; @@ -47,7 +48,7 @@ export function createRuntime(module: WebAssembly.Module, options: CreateOptions graph, provider, records: new RecordPool(), bodies: new Map(), pickTargets: new WeakMap(), byShape: new Map(), limbo: new Map(), listeners, revision: 0n, readyShapes: new Set(), renderPassActive: false, graphMoved: false, pass: 0, displayed: new Map(), - hostDrawn: new WeakMap(), + hostDrawn: new WeakMap(), groups: createHostGroups(), pending: new Map(), generations: new Map(), shapeBuckets: new Map(), displayBounds: new Map(), lod: createLodState(() => { refreshDisplayed(state); }), module, createOptions: options, poisoned: false, flushes: 0, marks: new Map(), flushing: false, surfacePool: new Map(), linePool: new Map(), epoch: nextEpoch(), diff --git a/main/opengeometry-three/src/runtime/runtime-state.ts b/main/opengeometry-three/src/runtime/runtime-state.ts index 670e7d12..56b4d69b 100644 --- a/main/opengeometry-three/src/runtime/runtime-state.ts +++ b/main/opengeometry-three/src/runtime/runtime-state.ts @@ -4,6 +4,7 @@ import type { Events, Listener } from '../dto/events'; import { OGError } from '../errors'; import type { OGWorldGraph } from '../kernel/kernel-loader'; import type { OGMark } from '../marks/og-mark'; +import type { HostGroups } from '../rendering/lod/host-groups'; import type { LodState } from '../rendering/lod/lod-controller'; import type { LineEntry, SurfaceEntry } from '../rendering/materials/material-pool'; import type { TessellationProvider } from '../rendering/provider'; @@ -29,6 +30,7 @@ export type Runtime = { pass: number; displayed: Map; hostDrawn: WeakMap; + groups: HostGroups; pending: Map>; generations: Map; shapeBuckets: Map; diff --git a/main/opengeometry-three/src/testing/internals.ts b/main/opengeometry-three/src/testing/internals.ts index 59f786ba..d4625b8d 100644 --- a/main/opengeometry-three/src/testing/internals.ts +++ b/main/opengeometry-three/src/testing/internals.ts @@ -1,5 +1,6 @@ import { OGError } from '../errors'; -import { forceNextBucket } from '../rendering/lod/lod-controller'; +import type { Body } from '../bodies/body'; +import { forceNextBucket, isDisplayed } from '../rendering/lod/lod-controller'; import { WorkerBackend, type WorkerSend } from '../rendering/worker-backend'; import { runtime } from '../runtime/runtime-state'; @@ -16,6 +17,8 @@ export function activeBackend(): 'worker' | 'inline' { return runtime().provider export function flushCount(): number { return runtime().flushes; } +export function displayed(body: Body): boolean { return isDisplayed(runtime(), body); } + export function pinWantedBucket(shapeId: string, bucket: number): void { forceNextBucket(runtime(), shapeId, bucket); } export function workerSendLog(): WorkerSend[] { diff --git a/main/opengeometry-three/tests/node/host-groups.test.mjs b/main/opengeometry-three/tests/node/host-groups.test.mjs new file mode 100644 index 00000000..4e2641b1 --- /dev/null +++ b/main/opengeometry-three/tests/node/host-groups.test.mjs @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import * as THREE from 'three'; +import { OpenGeometry, Solid, OG_PRIMITIVE_CUBOID } from '../../../dist/index.js'; +import { displayed, runtime } from '../../../dist/testing.js'; +import { boot, cuboid, failsWith, nextTask } from './support.mjs'; + +const RENDERER = { info: { render: { frame: 0 } }, getDrawingBufferSize: (target) => target.set(1000, 1000) }; +const CAMERA = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); +const BODY = Object.getPrototypeOf(Solid.prototype); + +async function fresh() { + await boot(); + OpenGeometry.reset(); + CAMERA.position.set(0, 0, 10); + CAMERA.updateMatrixWorld(); +} + +function current(body) { + return body.record?.shapeId === body.lastInfo.shapeId && body.record?.revision === body.lastInfo.shapeRevision; +} + +async function drawFrame(groups) { + for (const group of groups) group.drawn(); + await nextTask(); +} + +function counted(target, name) { + const original = target[name]; + const log = { calls: 0, stop: () => { Reflect.deleteProperty(target, name); } }; + target[name] = (...args) => { log.calls++; return Reflect.apply(original, target, args); }; + return log; +} + +function installsOn(body, step) { + const install = BODY.install; + let calls = 0; + BODY.install = function countedInstall(...rest) { + if (body === undefined || this === body) calls++; + return Reflect.apply(install, this, rest); + }; + try { + step(); + } finally { + BODY.install = install; + } + return calls; +} + +function asyncProvider() { + const provider = Object(runtime().provider); + const compute = provider.compute.bind(provider); + provider.compute = undefined; + provider.request = (request) => new Promise((resolve) => { setTimeout(() => { resolve(compute(request)); }, 1); }); + return () => { delete provider.compute; delete provider.request; }; +} + +function memberIds() { + return [...runtime().groups.live].flatMap((group) => group.members.map((body) => body.ogId)); +} + +async function steadyPassWork(size) { + await fresh(); + const bodies = Array.from({ length: size }, (_, index) => cuboid(`work-${String(size)}-${String(index)}`)); + const group = OpenGeometry.hostGroup(bodies); + try { + await drawFrame([group]); + assert.equal(bodies.every((body) => displayed(body) && current(body)), true, 'every member is drawn'); + const state = runtime(); + state.lod.lastSchedule = Date.now(); + const logs = [counted(state.groups.of, 'get'), counted(state.records, 'get'), counted(state.bodies, 'get')]; + let installs = 0; + try { + installs = installsOn(undefined, () => { group.drawn(); }); + await Promise.resolve(); + } finally { + for (const log of logs) log.stop(); + } + return { lookups: logs.map((log) => log.calls), installs, active: state.renderPassActive }; + } finally { + OpenGeometry.reset(); + } +} + +test('drawn() displays every member of a 1,000-body group and a later pass does no work per member', async () => { + const small = await steadyPassWork(10); + const large = await steadyPassWork(1000); + assert.deepEqual(large, small); + assert.deepEqual(large.lookups, [0, 0, 0]); + assert.equal(large.installs, 0); + assert.equal(large.active, false, 'the pass ends'); +}); + +test('a group not drawn for two passes leaves its members undisplayed and they get no swap', async () => { + await fresh(); + const [gone, kept] = [cuboid('group-gone'), cuboid('group-kept', 2)]; + const [goneGroup, keptGroup] = [OpenGeometry.hostGroup([gone]), OpenGeometry.hostGroup([kept])]; + try { + await drawFrame([goneGroup, keptGroup]); + assert.equal(displayed(gone), true); + assert.equal(current(gone), true); + await drawFrame([keptGroup]); + await drawFrame([keptGroup]); + assert.equal(displayed(gone), false); + const installs = installsOn(gone, () => { + gone.rebuild(OG_PRIMITIVE_CUBOID, { width: 3, height: 1, depth: 1 }); + OpenGeometry.flush(); + }); + await drawFrame([keptGroup]); + assert.equal(installs, 0); + assert.equal(current(gone), false); + } finally { + OpenGeometry.reset(); + } +}); + +test('worker geometry is installed on a member of a drawn group and not on one of an undrawn group', async () => { + await fresh(); + const restore = asyncProvider(); + const shown = cuboid('arrive-shown'); + const idle = shown.instance({ ogId: 'arrive-idle' }); + OpenGeometry.flush(); + const shownGroup = OpenGeometry.hostGroup([shown]); + OpenGeometry.hostGroup([idle]); + try { + assert.equal(idle.lastInfo.shapeId, shown.lastInfo.shapeId); + shownGroup.drawn(); + assert.equal(shown.record, undefined, 'no install inside the draw'); + for (let index = 0; index < 10 && !current(shown); index++) await drawFrame([shownGroup]); + assert.equal(current(shown), true); + assert.equal(displayed(idle), false); + assert.equal(idle.record, undefined); + } finally { + restore(); + OpenGeometry.reset(); + } +}); + +test('a member of a drawn group gets new geometry after its shape changes', async () => { + await fresh(); + const body = cuboid('group-edit'); + const group = OpenGeometry.hostGroup([body]); + try { + await drawFrame([group]); + const before = body.record; + body.rebuild(OG_PRIMITIVE_CUBOID, { width: 4, height: 1, depth: 1 }); + await drawFrame([group]); + assert.notEqual(body.record, before); + assert.equal(current(body), true); + } finally { + OpenGeometry.reset(); + } +}); + +test('settled, a sync flush and level of detail see a member of a drawn group', async () => { + await fresh(); + const body = cuboid('group-settle'); + const group = OpenGeometry.hostGroup([body]); + try { + Reflect.apply(OpenGeometry.observeCamera, OpenGeometry, [RENDERER, CAMERA]); + await drawFrame([group]); + body.rebuild(OG_PRIMITIVE_CUBOID, { width: 2, height: 1, depth: 1 }); + OpenGeometry.flush({ geometry: 'sync' }); + assert.equal(current(body), true, 'a sync flush installs a group member'); + const { failed } = await OpenGeometry.settled(); + assert.deepEqual(failed, []); + assert.equal(typeof runtime().lod.shapes.get(String(body.lastInfo.shapeId))?.target, 'number'); + } finally { + OpenGeometry.reset(); + } +}); + +test('set() replaces the members and dispose() stops the group counting', async () => { + await fresh(); + const [first, second] = [cuboid('set-first'), cuboid('set-second', 2)]; + const group = OpenGeometry.hostGroup([first]); + try { + await drawFrame([group]); + assert.equal(displayed(first), true); + assert.equal(displayed(second), false); + group.set([second, second]); + assert.equal(displayed(first), false); + assert.equal(displayed(second), true); + assert.deepEqual(memberIds(), ['set-second']); + await drawFrame([group]); + assert.equal(current(second), true); + group.dispose(); + assert.equal(displayed(second), false); + group.drawn(); + assert.equal(runtime().renderPassActive, false, 'a disposed group starts no pass'); + assert.throws(() => group.set([first]), failsWith('Disposed')); + group.dispose(); + assert.deepEqual(memberIds(), []); + } finally { + OpenGeometry.reset(); + } +}); + +test('a body in two groups is displayed while either group is drawn', async () => { + await fresh(); + const body = cuboid('two-groups'); + const [one, two] = [OpenGeometry.hostGroup([body]), OpenGeometry.hostGroup([body])]; + const other = OpenGeometry.hostGroup([cuboid('two-other', 2)]); + try { + await drawFrame([two]); + assert.equal(displayed(body), true); + two.dispose(); + assert.equal(displayed(body), false); + await drawFrame([one]); + assert.equal(displayed(body), true); + await drawFrame([other]); + await drawFrame([other]); + assert.equal(displayed(body), false); + } finally { + OpenGeometry.reset(); + } +}); + +test('drawn() skips a disposed member and the group drops it', async () => { + await fresh(); + const [kept, gone] = [cuboid('member-kept'), cuboid('member-gone', 2)]; + const group = OpenGeometry.hostGroup([kept, gone]); + try { + gone.dispose(); + await drawFrame([group]); + assert.equal(current(kept), true); + assert.equal(displayed(gone), false); + assert.deepEqual(memberIds(), ['member-kept']); + } finally { + OpenGeometry.reset(); + } +}); + +test('hostGroup and set reject what markDisplayed rejects, and a reset ends the group', async () => { + await fresh(); + const body = cuboid('group-input'); + const disposed = cuboid('group-disposed', 2); + disposed.dispose(); + const invalid = failsWith('InvalidParameter'); + assert.throws(() => Reflect.apply(OpenGeometry.hostGroup, OpenGeometry, [body]), invalid); + assert.throws(() => Reflect.apply(OpenGeometry.hostGroup, OpenGeometry, [[new THREE.Mesh()]]), invalid); + assert.throws(() => OpenGeometry.hostGroup([disposed]), failsWith('Disposed')); + const group = OpenGeometry.hostGroup([]); + assert.throws(() => Reflect.apply(group.set, group, [[body, new THREE.Mesh()]]), invalid); + assert.throws(() => group.set([disposed]), failsWith('Disposed')); + group.set([body]); + OpenGeometry.reset(); + group.drawn(); + assert.equal(runtime().renderPassActive, false); + assert.throws(() => group.set([]), failsWith('Disposed')); + OpenGeometry.reset(); +}); + +test('a host-drawn member of a drawn group keeps its own visibility', async () => { + await fresh(); + const [hosted, hidden] = [cuboid('group-hosted'), cuboid('group-hidden', 2)]; + hidden.visible = false; + OpenGeometry.setHostDrawn([hosted, hidden], true); + const group = OpenGeometry.hostGroup([hosted, hidden]); + try { + await drawFrame([group]); + assert.equal(displayed(hosted), true); + assert.equal(current(hosted), true); + assert.equal(displayed(hidden), false); + assert.equal(hidden.record, undefined); + } finally { + OpenGeometry.reset(); + } +}); From b6e188bbb05e0233d918df486f25f769babe9660 Mon Sep 17 00:00:00 2001 From: Vishwajeet Mane Date: Wed, 4 Nov 2026 23:44:30 +0100 Subject: [PATCH 9/9] fix(render): host groups refresh on a temporary target pop and on a member shown, evaluate drawn groups only, and drop disposed members (K-08) Signed-off-by: Vishwajeet Mane --- README.md | 2 +- .../src/bodies/body-registry.ts | 2 + main/opengeometry-three/src/bodies/body.ts | 3 +- .../rendering/geometry/geometry-scheduler.ts | 6 +- .../src/rendering/geometry/host-group.ts | 4 +- .../src/rendering/geometry/render-pass.ts | 3 +- .../src/rendering/geometry/settle.ts | 3 +- .../src/rendering/lod/displayed.ts | 30 +++++ .../src/rendering/lod/host-drawn.ts | 10 ++ .../src/rendering/lod/host-groups.ts | 63 +++++++--- .../src/rendering/lod/lod-controller.ts | 37 ++---- .../src/runtime/create-runtime.ts | 7 +- .../src/testing/internals.ts | 3 +- .../tests/node/host-groups.test.mjs | 109 +++++++++++++++++- main/scripts/lib/node-builtins.d.ts | 8 ++ 15 files changed, 231 insertions(+), 59 deletions(-) create mode 100644 main/opengeometry-three/src/rendering/lod/displayed.ts diff --git a/README.md b/README.md index 39ce4912..33535aa5 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)`; `OpenGeometry.observeCamera(renderer, camera)` observes the first camera seen per renderer frame (`renderer.info.render.frame`) for level of detail; a `render()` nested inside an `onBeforeRender` (Reflector style) starts a new frame, and an `ArrayCamera` is observed through its first sub-camera (a host whose drawables are not kernel bodies, such as instanced or batched layers, calls it from their `onBeforeRender`); `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`; `OpenGeometry.markDisplayed(bodies)` counts those bodies as displayed for the current render pass (a host that draws bodies through its own instanced or batched layers calls it from that layer's `onBeforeRender`; it works for a body hidden by layer, skips a body with `visible = false`, and validates `bodies` as `flush({ bodies })` does); `OpenGeometry.setHostDrawn(bodies, hostDrawn)` hands the drawing of those bodies to the host (`true`) or gives it back (`false`): while host-drawn, a body's `visible` is `false`, so three.js skips it and everything under it, and the kernel keeps the body's own visibility apart, so `markDisplayed`, a walk, `flush({ geometry: 'sync', bodies })`, level of detail and record swaps treat it as shown, and a dispose, a rollback or a reset changes that visibility instead of `visible`; giving it back sets `visible` to that visibility (a host calls it when a body joins or leaves its instanced or batched layers; do not set `visible` on a host-drawn body; it validates `bodies` as `flush({ bodies })` does, except that giving bodies back ignores a disposed or finalized body, and a `hostDrawn` that is not a boolean throws `InvalidParameter`); `OpenGeometry.hostGroup(bodies)` returns a `HostGroup` that stamps all its bodies at once: `group.drawn()` counts every member as displayed for the current render pass exactly as `markDisplayed` does, at a cost that does not grow with the number of members (a host calls it from each instanced or batched layer's `onBeforeRender` in place of `markDisplayed`), `group.set(bodies)` replaces the members and `group.dispose()` ends the group, after which `drawn()` does nothing and its members no longer count through it; a body may be in several groups, a hidden or host-drawn member keeps its own visibility, a member disposed later is skipped and dropped, `hostGroup` and `set` validate `bodies` once as `markDisplayed` does, and `set` on a disposed group or one from a reset runtime throws `Disposed`. A body is displayed when it is drawn (its own `onBeforeRender` or `markDisplayed`) or walked by `updateMatrixWorld`; a draw installs new display geometry after the render, a walk at once. Each body dispatches `geometrychanged` after it installs new display geometry and `placementchanged` when a flush moves it (`BodyEventMap`); their listeners run synchronously, after the whole flush is applied or at the end of the render pass, and a listener that throws is reported through `OpenGeometry.on('error', …)` with the thrown value while the flush and the pass complete. | +| 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.observeCamera(renderer, camera)` observes the first camera seen per renderer frame (`renderer.info.render.frame`) for level of detail; a `render()` nested inside an `onBeforeRender` (Reflector style) starts a new frame, and an `ArrayCamera` is observed through its first sub-camera (a host whose drawables are not kernel bodies, such as instanced or batched layers, calls it from their `onBeforeRender`); `OpenGeometry.releaseIdleGeometry()` disposes the display geometry cached for bodies not showing it and returns `{ records, bytes }`; `OpenGeometry.markDisplayed(bodies)` counts those bodies as displayed for the current render pass (a host that draws bodies through its own instanced or batched layers calls it from that layer's `onBeforeRender`; it works for a body hidden by layer, skips a body with `visible = false`, and validates `bodies` as `flush({ bodies })` does); `OpenGeometry.setHostDrawn(bodies, hostDrawn)` hands the drawing of those bodies to the host (`true`) or gives it back (`false`): while host-drawn, a body's `visible` is `false`, so three.js skips it and everything under it, and the kernel keeps the body's own visibility apart, so `markDisplayed`, a walk, `flush({ geometry: 'sync', bodies })`, level of detail and record swaps treat it as shown, and a dispose, a rollback or a reset changes that visibility instead of `visible`; giving it back sets `visible` to that visibility (a host calls it when a body joins or leaves its instanced or batched layers; do not set `visible` on a host-drawn body; it validates `bodies` as `flush({ bodies })` does, except that giving bodies back ignores a disposed or finalized body, and a `hostDrawn` that is not a boolean throws `InvalidParameter`); `OpenGeometry.hostGroup(bodies)` returns a `HostGroup` that stamps all its bodies at once: `group.drawn()` counts every member as displayed for the current render pass exactly as `markDisplayed` does, at a cost that does not grow with the number of members (a host calls it from each instanced or batched layer's `onBeforeRender` in place of `markDisplayed`), `group.set(bodies)` replaces the members and `group.dispose()` ends the group, after which `drawn()` does nothing and its members no longer count through it; a body may be in several groups, a hidden or host-drawn member keeps its own visibility and gets its geometry when it is shown again through `setHostDrawn` or a rollback, a member that is not host-drawn and has `visible` set to `true` later gets it when it is drawn or walked, a member disposed later leaves its groups, a group that is dropped without `dispose()` is ended when it is garbage collected, `hostGroup` and `set` validate `bodies` once as `markDisplayed` does, and `set` on a disposed group or one from a reset runtime throws `Disposed`. A body is displayed when it is drawn (its own `onBeforeRender` or `markDisplayed`) or walked by `updateMatrixWorld`; a draw installs new display geometry after the render, a walk at once. Each body dispatches `geometrychanged` after it installs new display geometry and `placementchanged` when a flush moves it (`BodyEventMap`); their listeners run synchronously, after the whole flush is applied or at the end of the render pass, and a listener that throws is reported through `OpenGeometry.on('error', …)` with the thrown value while the flush and the pass complete. | | 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/bodies/body-registry.ts b/main/opengeometry-three/src/bodies/body-registry.ts index 84aee7ca..5fb3273f 100644 --- a/main/opengeometry-three/src/bodies/body-registry.ts +++ b/main/opengeometry-three/src/bodies/body-registry.ts @@ -1,3 +1,4 @@ +import { dropMember } from '../rendering/lod/host-groups'; import { bodyKey, reindexShape, releaseShapeIfEmpty, runtime } from '../runtime/runtime-state'; import type { Body } from './body'; @@ -11,6 +12,7 @@ export function register(body: Body): void { export function unregister(body: Body): void { const state = runtime(); state.displayed.delete(body); + dropMember(state, body); state.displayBounds.delete(body); state.lod.stale = true; state.limbo.delete(body); diff --git a/main/opengeometry-three/src/bodies/body.ts b/main/opengeometry-three/src/bodies/body.ts index 02d67eba..51eea5a0 100644 --- a/main/opengeometry-three/src/bodies/body.ts +++ b/main/opengeometry-three/src/bodies/body.ts @@ -11,7 +11,8 @@ import { flush, flushForDispose, notifyBody, wanted } from '../rendering/geometr import { noteDisplayed, noteDrawn } from '../rendering/geometry/render-pass'; import { observeCamera } from '../rendering/lod/camera-observer'; import { shown } from '../rendering/lod/host-drawn'; -import { isDisplayed, overrideChanged } from '../rendering/lod/lod-controller'; +import { isDisplayed } from '../rendering/lod/displayed'; +import { overrideChanged } from '../rendering/lod/lod-controller'; import { lineMaterial, releaseLine, releaseSurface, surfaceMaterial, type MaterialAppearance, } from '../rendering/materials/material-pool'; diff --git a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts index 74fde720..eaa9d131 100644 --- a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts +++ b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts @@ -8,12 +8,10 @@ import type { Body } from '../../bodies/body'; import { emit } from '../../runtime/event-bus'; import { bodyKey, currentRuntime, reindexShape, runtime, type Runtime } from '../../runtime/runtime-state'; import { decodeChangeSet } from '../../world-graph/codec'; +import { displayedBodies, isDisplayed, stampDisplayed } from '../lod/displayed'; import { shown } from '../lod/host-drawn'; import { queueGrouped } from '../lod/host-groups'; -import { - clearError, displayedBodies, isDisplayed, markFailed, recordError, retryBucket, shapePriority, stampDisplayed, - wantedKey, -} from '../lod/lod-controller'; +import { clearError, markFailed, recordError, retryBucket, shapePriority, wantedKey } from '../lod/lod-controller'; import type { TessellationRequest } from '../provider'; import { GeometryRecord } from '../records/geometry-record'; import { validateDisplayBuffers } from '../records/validate-display-buffers'; diff --git a/main/opengeometry-three/src/rendering/geometry/host-group.ts b/main/opengeometry-three/src/rendering/geometry/host-group.ts index 031b8236..3810957b 100644 --- a/main/opengeometry-three/src/rendering/geometry/host-group.ts +++ b/main/opengeometry-three/src/rendering/geometry/host-group.ts @@ -11,12 +11,13 @@ function members(state: Runtime, bodies: readonly Body[], label: string): Body[] export class HostGroup { private readonly state: Runtime = runtime(); - private readonly group: GroupState = { pass: -Infinity, members: [] }; + private readonly group: GroupState = { pass: -Infinity, members: new Set() }; private disposed = false; constructor(bodies: readonly Body[]) { setMembers(this.state, this.group, members(this.state, bodies, 'OpenGeometry.hostGroup')); this.state.groups.live.add(this.group); + this.state.groups.dropped.register(this, this.group, this); } drawn(): void { @@ -35,6 +36,7 @@ export class HostGroup { dispose(): void { if (this.disposed) return; this.disposed = true; + this.state.groups.dropped.unregister(this); if (currentRuntime() === this.state) dropGroup(this.state, this.group); } } diff --git a/main/opengeometry-three/src/rendering/geometry/render-pass.ts b/main/opengeometry-three/src/rendering/geometry/render-pass.ts index 4eacc113..60cffa4a 100644 --- a/main/opengeometry-three/src/rendering/geometry/render-pass.ts +++ b/main/opengeometry-three/src/rendering/geometry/render-pass.ts @@ -2,8 +2,9 @@ import type { Body } from '../../bodies/body'; import { OGError } from '../../errors'; import { currentRuntime, runtime, type Runtime } from '../../runtime/runtime-state'; import { handToHost, shown } from '../lod/host-drawn'; +import { displayedBodies, isDisplayed, stampDisplayed, wakeDisplayed } from '../lod/displayed'; import { takeWaiting } from '../lod/host-groups'; -import { displayedBodies, evaluatePass, isDisplayed, stampDisplayed, wakeDisplayed } from '../lod/lod-controller'; +import { evaluatePass } from '../lod/lod-controller'; import { checkedBodies, ensureGeometry, flush } from './geometry-scheduler'; export function noteDisplayed(body: Body): void { diff --git a/main/opengeometry-three/src/rendering/geometry/settle.ts b/main/opengeometry-three/src/rendering/geometry/settle.ts index e52f4432..da23c69d 100644 --- a/main/opengeometry-three/src/rendering/geometry/settle.ts +++ b/main/opengeometry-three/src/rendering/geometry/settle.ts @@ -1,7 +1,8 @@ import { OGError } from '../../errors'; import { runtime, type Runtime } from '../../runtime/runtime-state'; +import { displayedBodies } from '../lod/displayed'; import { - capturedError, displayedBodies, popTemporaryTarget, pushTemporaryTarget, settleNow, wantedKey, + capturedError, popTemporaryTarget, pushTemporaryTarget, settleNow, wantedKey, } from '../lod/lod-controller'; import { flush } from './geometry-scheduler'; import { refreshDisplayed } from './render-pass'; diff --git a/main/opengeometry-three/src/rendering/lod/displayed.ts b/main/opengeometry-three/src/rendering/lod/displayed.ts new file mode 100644 index 00000000..66c0716e --- /dev/null +++ b/main/opengeometry-three/src/rendering/lod/displayed.ts @@ -0,0 +1,30 @@ +import type { Body } from '../../bodies/body'; +import type { Runtime } from '../../runtime/runtime-state'; +import { shown } from './host-drawn'; +import { groupDisplayed, groupedBodies } from './host-groups'; + +export function isDisplayed(state: Runtime, body: Body): boolean { + if (!shown(state, body)) return false; + return (state.displayed.get(body) ?? -Infinity) >= state.pass - 1 || groupDisplayed(state, body); +} + +export function displayedBodies(state: Runtime): Body[] { + return [...new Set([...state.displayed.keys(), ...groupedBodies(state)])].filter((body) => isDisplayed(state, body)); +} + +export function stampedCount(state: Runtime): number { + let count = 0; + for (const body of state.displayed.keys()) if (isDisplayed(state, body)) count++; + return count; +} + +export function stampDisplayed(state: Runtime, body: Body): void { + if (!isDisplayed(state, body)) wakeDisplayed(state, body); + state.displayed.set(body, state.pass); +} + +export function wakeDisplayed(state: Runtime, body: Body): void { + const shapeId = body.lastInfo.shapeId; + state.lod.stale = true; + if (shapeId) delete state.lod.shapes.get(shapeId)?.overrides; +} diff --git a/main/opengeometry-three/src/rendering/lod/host-drawn.ts b/main/opengeometry-three/src/rendering/lod/host-drawn.ts index 8c96a1ca..7095ea64 100644 --- a/main/opengeometry-three/src/rendering/lod/host-drawn.ts +++ b/main/opengeometry-three/src/rendering/lod/host-drawn.ts @@ -1,16 +1,26 @@ import type { Body } from '../../bodies/body'; import type { Runtime } from '../../runtime/runtime-state'; +import { shownChanged } from './host-groups'; export function shown(state: Runtime, body: Body): boolean { return body.visible || state.hostDrawn.get(body) === true; } export function setShown(state: Runtime, body: Body, value: boolean): void { + const before = shown(state, body); if (state.hostDrawn.has(body)) state.hostDrawn.set(body, value); else body.visible = value; + if (shown(state, body) !== before) shownChanged(state, body); } export function handToHost(state: Runtime, body: Body, hostDrawn: boolean): void { + const handed = hostDrawn && !state.hostDrawn.has(body); + const before = shown(state, body); + shareDrawing(state, body, hostDrawn); + if (handed ? shown(state, body) : shown(state, body) !== before) shownChanged(state, body); +} + +function shareDrawing(state: Runtime, body: Body, hostDrawn: boolean): void { const logical = state.hostDrawn.get(body); if (hostDrawn && logical === undefined) { state.hostDrawn.set(body, body.visible); diff --git a/main/opengeometry-three/src/rendering/lod/host-groups.ts b/main/opengeometry-three/src/rendering/lod/host-groups.ts index f1aec3e7..28b4c3dc 100644 --- a/main/opengeometry-three/src/rendering/lod/host-groups.ts +++ b/main/opengeometry-three/src/rendering/lod/host-groups.ts @@ -1,7 +1,7 @@ import type { Body } from '../../bodies/body'; -import { bodyKey, type Runtime } from '../../runtime/runtime-state'; +import type { Runtime } from '../../runtime/runtime-state'; -export type GroupState = { pass: number; members: readonly Body[] }; +export type GroupState = { pass: number; members: Set }; export type HostGroups = { live: Set; @@ -9,10 +9,15 @@ export type HostGroups = { woken: Set; pending: Set; drawn: boolean; + revision: number; + dropped: FinalizationRegistry; }; -export function createHostGroups(): HostGroups { - return { live: new Set(), of: new WeakMap(), woken: new Set(), pending: new Set(), drawn: false }; +export function createHostGroups(release: (group: GroupState) => void): HostGroups { + return { + live: new Set(), of: new WeakMap(), woken: new Set(), pending: new Set(), drawn: false, revision: 0, + dropped: new FinalizationRegistry(release), + }; } export function groupShown(state: Runtime, group: GroupState): boolean { @@ -27,18 +32,16 @@ export function groupDisplayed(state: Runtime, body: Body): boolean { } export function stampGroup(state: Runtime, group: GroupState): void { - if (!groupShown(state, group)) { - state.lod.stale = true; - state.groups.woken.add(group); - } + if (!groupShown(state, group)) wake(state, group); state.groups.drawn = true; group.pass = state.pass; } export function setMembers(state: Runtime, group: GroupState, members: readonly Body[]): void { for (const body of group.members) leave(state, body, group); - group.members = members; - for (const body of members) join(state, body, group); + group.members = new Set(members); + for (const body of group.members) join(state, body, group); + state.groups.revision++; state.lod.stale = true; if (groupShown(state, group)) state.groups.woken.add(group); } @@ -50,34 +53,62 @@ export function dropGroup(state: Runtime, group: GroupState): void { state.groups.woken.delete(group); } -export function liveMembers(state: Runtime, group: GroupState): readonly Body[] { - const live = group.members.filter((body) => state.bodies.get(bodyKey(body.handle, body.generation)) === body); - if (live.length !== group.members.length) group.members = live; - return live; +export function dropMember(state: Runtime, body: Body): void { + for (const group of state.groups.of.get(body) ?? []) group.members.delete(body); + state.groups.of.delete(body); + state.groups.pending.delete(body); + state.groups.revision++; } export function groupedBodies(state: Runtime): Body[] { const bodies: Body[] = []; for (const group of state.groups.live) { - if (groupShown(state, group)) for (const body of liveMembers(state, group)) bodies.push(body); + if (groupShown(state, group)) for (const body of group.members) bodies.push(body); } return bodies; } +export function groupsKey(state: Runtime): number { + let members = 0; + for (const group of state.groups.live) if (groupShown(state, group)) members += group.members.size + 1; + return members; +} + export function queueGrouped(state: Runtime, body: Body): void { if (state.groups.of.get(body)?.length) state.groups.pending.add(body); } +export function shownChanged(state: Runtime, body: Body): void { + if (!state.groups.of.get(body)?.length) return; + state.groups.pending.add(body); + state.groups.revision++; + state.lod.stale = true; +} + +export function queueShapes(state: Runtime, shapeId?: string): void { + if (shapeId === undefined) { + for (const group of state.groups.live) if (groupShown(state, group)) state.groups.woken.add(group); + return; + } + for (const body of state.byShape.get(shapeId) ?? []) queueGrouped(state, body); +} + export function takeWaiting(state: Runtime): Set { const groups = state.groups; const waiting = new Set(groups.pending); - for (const group of groups.woken) for (const body of liveMembers(state, group)) waiting.add(body); + for (const group of groups.woken) for (const body of group.members) waiting.add(body); groups.pending.clear(); groups.woken.clear(); groups.drawn = false; return waiting; } +function wake(state: Runtime, group: GroupState): void { + state.lod.stale = true; + state.groups.revision++; + state.groups.woken.add(group); +} + function join(state: Runtime, body: Body, group: GroupState): void { const groups = state.groups.of.get(body); if (groups) groups.push(group); diff --git a/main/opengeometry-three/src/rendering/lod/lod-controller.ts b/main/opengeometry-three/src/rendering/lod/lod-controller.ts index cb008363..ed18746d 100644 --- a/main/opengeometry-three/src/rendering/lod/lod-controller.ts +++ b/main/opengeometry-three/src/rendering/lod/lod-controller.ts @@ -6,15 +6,15 @@ import { currentRuntime, type Runtime } from '../../runtime/runtime-state'; import { finestLocal, localTarget, shapeBuckets, type ShapeBuckets } from './body-size'; import { deflectionBucket, wantedBucket } from './deflection'; import { displayBounds } from './display-bounds'; -import { groupDisplayed, groupedBodies } from './host-groups'; -import { shown } from './host-drawn'; +import { displayedBodies, isDisplayed, stampedCount } from './displayed'; +import { groupsKey, queueShapes } from './host-groups'; import { worldUnitsPerPixel } from './screen-metrics'; type Tiers = { temporary: number | undefined; own: number | undefined; global: number | undefined }; type SeenCamera = { camera: THREE.Camera; height: number }; type ShapeTarget = { target: number; body: Body }; type TemporaryTarget = { deflection: number }; -type PassInputs = { revision: bigint; flushed: bigint; moving: boolean; displayed: number }; +type PassInputs = { revision: bigint; flushed: bigint; moving: boolean; displayed: number; groups: number[] }; type ShapeLod = { shapeId: string; @@ -53,26 +53,6 @@ export function createLodState(refresh: () => void): LodState { }; } -export function isDisplayed(state: Runtime, body: Body): boolean { - if (!shown(state, body)) return false; - return (state.displayed.get(body) ?? -Infinity) >= state.pass - 1 || groupDisplayed(state, body); -} - -export function displayedBodies(state: Runtime): Body[] { - return [...new Set([...state.displayed.keys(), ...groupedBodies(state)])].filter((body) => isDisplayed(state, body)); -} - -export function stampDisplayed(state: Runtime, body: Body): void { - if (!isDisplayed(state, body)) wakeDisplayed(state, body); - state.displayed.set(body, state.pass); -} - -export function wakeDisplayed(state: Runtime, body: Body): void { - const shapeId = body.lastInfo.shapeId; - state.lod.stale = true; - if (shapeId) delete state.lod.shapes.get(shapeId)?.overrides; -} - export function observe(state: Runtime, camera: THREE.Camera, height: number, now: number): void { const lod = state.lod; const moved = storeChanged(lod.camera, camera.matrixWorld.elements, 0); @@ -116,10 +96,11 @@ export function evaluatePass(state: Runtime, now: number, forced: boolean): bool if (!seen || state.poisoned || (!forced && now - lod.lastSchedule < EVALUATION_INTERVAL_MS)) return false; lod.lastSchedule = now; const moving = !forced && now - lod.lastMotion < MOTION_WINDOW_MS; - const bodies = displayedBodies(state); const revision = call('OpenGeometry.flush', () => state.graph.revision()); - const inputs = { revision, flushed: state.revision, moving, displayed: bodies.length }; + const groups = [state.groups.revision, groupsKey(state)]; + const inputs = { revision, flushed: state.revision, moving, displayed: stampedCount(state), groups }; if (!forced && !stalePass(lod, inputs)) return false; + const bodies = displayedBodies(state); lod.stale = false; lod.evaluated = inputs; let changed = false; @@ -139,7 +120,8 @@ export function evaluatePass(state: Runtime, now: number, forced: boolean): bool function stalePass(lod: LodState, inputs: PassInputs): boolean { const last = lod.evaluated; return lod.stale || !last || last.revision !== inputs.revision || last.flushed !== inputs.flushed - || last.moving !== inputs.moving || last.displayed !== inputs.displayed; + || last.moving !== inputs.moving || last.displayed !== inputs.displayed + || last.groups.some((value, index) => value !== inputs.groups[index]); } function shapeTargets( @@ -220,6 +202,7 @@ function failedKey(revision: number, bucket: number): string { export function overrideChanged(state: Runtime, shapeId?: string): void { state.lod.stale = true; + queueShapes(state, shapeId); const entries = shapeId === undefined ? [...state.lod.shapes.values()] : [state.lod.shapes.get(shapeId)]; for (const entry of entries) { if (!entry) continue; @@ -243,6 +226,7 @@ export function popTemporaryTarget(state: Runtime, token: TemporaryTarget): void function clearAllMarks(state: Runtime): void { state.lod.stale = true; + queueShapes(state); for (const entry of state.lod.shapes.values()) clearMarks(entry); } @@ -291,4 +275,5 @@ export function capturedError(state: Runtime, body: Body): unknown { export function forceNextBucket(state: Runtime, shapeId: string, bucket: number): void { shapeLod(state, shapeId).forced = bucket; state.lod.stale = true; + queueShapes(state, shapeId); } diff --git a/main/opengeometry-three/src/runtime/create-runtime.ts b/main/opengeometry-three/src/runtime/create-runtime.ts index 7fc9332c..5efe950f 100644 --- a/main/opengeometry-three/src/runtime/create-runtime.ts +++ b/main/opengeometry-three/src/runtime/create-runtime.ts @@ -4,14 +4,14 @@ import { compileKernel, initKernel, OGWorldGraph } from '../kernel/kernel-loader import { ACCURACY } from '../limits'; import { refreshDisplayed } from '../rendering/geometry/render-pass'; import { InlineBackend } from '../rendering/inline-backend'; -import { createHostGroups } from '../rendering/lod/host-groups'; +import { createHostGroups, dropGroup } from '../rendering/lod/host-groups'; import { createLodState } from '../rendering/lod/lod-controller'; import { RecordPool } from '../rendering/records/record-pool'; import { WorkerBackend } from '../rendering/worker-backend'; import { encode } from '../world-graph/codec'; import { emit } from './event-bus'; import { - nextEpoch, pendingCreate, setPendingCreate, setRuntime, type CreateOptions, type Runtime, + currentRuntime, nextEpoch, pendingCreate, setPendingCreate, setRuntime, type CreateOptions, type Runtime, } from './runtime-state'; export type KernelInput = { wasmURL?: string | URL; wasmModule?: WebAssembly.Module }; @@ -48,7 +48,8 @@ export function createRuntime(module: WebAssembly.Module, options: CreateOptions graph, provider, records: new RecordPool(), bodies: new Map(), pickTargets: new WeakMap(), byShape: new Map(), limbo: new Map(), listeners, revision: 0n, readyShapes: new Set(), renderPassActive: false, graphMoved: false, pass: 0, displayed: new Map(), - hostDrawn: new WeakMap(), groups: createHostGroups(), + hostDrawn: new WeakMap(), + groups: createHostGroups((group) => { if (currentRuntime() === state) dropGroup(state, group); }), pending: new Map(), generations: new Map(), shapeBuckets: new Map(), displayBounds: new Map(), lod: createLodState(() => { refreshDisplayed(state); }), module, createOptions: options, poisoned: false, flushes: 0, marks: new Map(), flushing: false, surfacePool: new Map(), linePool: new Map(), epoch: nextEpoch(), diff --git a/main/opengeometry-three/src/testing/internals.ts b/main/opengeometry-three/src/testing/internals.ts index d4625b8d..1df0a108 100644 --- a/main/opengeometry-three/src/testing/internals.ts +++ b/main/opengeometry-three/src/testing/internals.ts @@ -1,6 +1,7 @@ import { OGError } from '../errors'; import type { Body } from '../bodies/body'; -import { forceNextBucket, isDisplayed } from '../rendering/lod/lod-controller'; +import { isDisplayed } from '../rendering/lod/displayed'; +import { forceNextBucket } from '../rendering/lod/lod-controller'; import { WorkerBackend, type WorkerSend } from '../rendering/worker-backend'; import { runtime } from '../runtime/runtime-state'; diff --git a/main/opengeometry-three/tests/node/host-groups.test.mjs b/main/opengeometry-three/tests/node/host-groups.test.mjs index 4e2641b1..53f27425 100644 --- a/main/opengeometry-three/tests/node/host-groups.test.mjs +++ b/main/opengeometry-three/tests/node/host-groups.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { setFlagsFromString } from 'node:v8'; +import { runInNewContext } from 'node:vm'; import * as THREE from 'three'; import { OpenGeometry, Solid, OG_PRIMITIVE_CUBOID } from '../../../dist/index.js'; import { displayed, runtime } from '../../../dist/testing.js'; @@ -8,6 +10,8 @@ import { boot, cuboid, failsWith, nextTask } from './support.mjs'; const RENDERER = { info: { render: { frame: 0 } }, getDrawingBufferSize: (target) => target.set(1000, 1000) }; const CAMERA = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); const BODY = Object.getPrototypeOf(Solid.prototype); +setFlagsFromString('--expose-gc'); +const COLLECT = runInNewContext('gc'); async function fresh() { await boot(); @@ -16,6 +20,20 @@ async function fresh() { CAMERA.updateMatrixWorld(); } +function ortho(target) { + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000); + camera.position.set(0, 0, 10); + camera.zoom = 1 / (1000 * target); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(); + return camera; +} + +function observe(camera) { + RENDERER.info.render.frame++; + Reflect.apply(OpenGeometry.observeCamera, OpenGeometry, [RENDERER, camera]); +} + function current(body) { return body.record?.shapeId === body.lastInfo.shapeId && body.record?.revision === body.lastInfo.shapeRevision; } @@ -56,7 +74,7 @@ function asyncProvider() { } function memberIds() { - return [...runtime().groups.live].flatMap((group) => group.members.map((body) => body.ogId)); + return [...runtime().groups.live].flatMap((group) => [...group.members].map((body) => body.ogId)); } async function steadyPassWork(size) { @@ -64,10 +82,14 @@ async function steadyPassWork(size) { const bodies = Array.from({ length: size }, (_, index) => cuboid(`work-${String(size)}-${String(index)}`)); const group = OpenGeometry.hostGroup(bodies); try { - await drawFrame([group]); + for (let index = 0; index < 2; index++) { + observe(CAMERA); + await drawFrame([group]); + } assert.equal(bodies.every((body) => displayed(body) && current(body)), true, 'every member is drawn'); const state = runtime(); - state.lod.lastSchedule = Date.now(); + state.lod.lastSchedule = 0; + observe(CAMERA); const logs = [counted(state.groups.of, 'get'), counted(state.records, 'get'), counted(state.bodies, 'get')]; let installs = 0; try { @@ -76,7 +98,8 @@ async function steadyPassWork(size) { } finally { for (const log of logs) log.stop(); } - return { lookups: logs.map((log) => log.calls), installs, active: state.renderPassActive }; + const evaluated = state.lod.lastSchedule > 0; + return { lookups: logs.map((log) => log.calls), installs, active: state.renderPassActive, evaluated }; } finally { OpenGeometry.reset(); } @@ -89,6 +112,7 @@ test('drawn() displays every member of a 1,000-body group and a later pass does assert.deepEqual(large.lookups, [0, 0, 0]); assert.equal(large.installs, 0); assert.equal(large.active, false, 'the pass ends'); + assert.equal(large.evaluated, true, 'the level-of-detail evaluation runs in the measured pass'); }); test('a group not drawn for two passes leaves its members undisplayed and they get no swap', async () => { @@ -267,3 +291,80 @@ test('a host-drawn member of a drawn group keeps its own visibility', async () = OpenGeometry.reset(); } }); + +test('a member of a drawn group returns to its own bucket after settled with a deflection', async () => { + await fresh(); + const camera = ortho(2 ** -10); + const [stamped, member] = [cuboid('settle-stamped'), cuboid('settle-member')]; + const group = OpenGeometry.hostGroup([member]); + const frames = async () => { + for (let index = 0; index < 4; index++) { + runtime().lod.lastSchedule = 0; + observe(camera); + OpenGeometry.markDisplayed([stamped]); + await drawFrame([group]); + } + }; + try { + await frames(); + const resting = stamped.record?.bucket; + assert.equal(member.record?.bucket, resting); + await OpenGeometry.settled({ deflection: 0.05 }); + assert.notEqual(member.record?.bucket, resting, 'settled meshes at its deflection'); + await frames(); + assert.equal(stamped.record?.bucket, resting); + assert.equal(member.record?.bucket, resting); + } finally { + OpenGeometry.reset(); + } +}); + +test('a host-drawn member shown again while its group is drawn gets its geometry', async () => { + await fresh(); + const member = cuboid('shown-later'); + member.visible = false; + OpenGeometry.setHostDrawn([member], true); + const group = OpenGeometry.hostGroup([member]); + try { + await drawFrame([group]); + await drawFrame([group]); + assert.equal(member.record, undefined); + OpenGeometry.setHostDrawn([member], false); + member.visible = true; + OpenGeometry.setHostDrawn([member], true); + await drawFrame([group]); + assert.equal(displayed(member), true); + assert.equal(current(member), true); + } finally { + OpenGeometry.reset(); + } +}); + +test('a disposed member leaves an undrawn group at once', async () => { + await fresh(); + const [kept, gone] = [cuboid('undrawn-kept'), cuboid('undrawn-gone', 2)]; + OpenGeometry.hostGroup([kept, gone]); + try { + gone.dispose(); + assert.deepEqual(memberIds(), ['undrawn-kept']); + } finally { + OpenGeometry.reset(); + } +}); + +test('a host group dropped without dispose is released by the garbage collector', async () => { + await fresh(); + const body = cuboid('dropped-group'); + (() => { OpenGeometry.hostGroup([body]).drawn(); })(); + try { + await nextTask(); + for (let index = 0; index < 20 && runtime().groups.live.size > 0; index++) { + Reflect.apply(Object(COLLECT), undefined, []); + await nextTask(); + } + assert.equal(runtime().groups.live.size, 0); + assert.equal(displayed(body), false); + } finally { + OpenGeometry.reset(); + } +}); diff --git a/main/scripts/lib/node-builtins.d.ts b/main/scripts/lib/node-builtins.d.ts index 28174de6..785f8cd9 100644 --- a/main/scripts/lib/node-builtins.d.ts +++ b/main/scripts/lib/node-builtins.d.ts @@ -92,6 +92,14 @@ declare module 'node:perf_hooks' { export const performance: { now(): number }; } +declare module 'node:v8' { + export function setFlagsFromString(flags: string): void; +} + +declare module 'node:vm' { + export function runInNewContext(code: string): unknown; +} + declare const process: { argv: string[]; arch: string;