diff --git a/MIGRATION.md b/MIGRATION.md index 0fdbf849..8c144504 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,6 +12,13 @@ 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 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 2.5 was not published. From 2.0, follow 2.0 to 2.5, then 2.5 to 3.0. diff --git a/README.md b/README.md index 1da9003c..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.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); `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/index.ts b/main/opengeometry-three/index.ts index ebccc1a5..62ea1cbf 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -1,9 +1,11 @@ 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'; 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-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 e56a1088..51eea5a0 100644 --- a/main/opengeometry-three/src/bodies/body.ts +++ b/main/opengeometry-three/src/bodies/body.ts @@ -7,10 +7,12 @@ 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 { shown } from '../rendering/lod/host-drawn'; +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'; @@ -24,7 +26,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'; @@ -38,7 +40,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; @@ -74,15 +78,17 @@ 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); - 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))); } @@ -97,7 +103,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); } @@ -121,10 +127,13 @@ 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'; this.outline.visible = this.appearance.outline; + notifyBody(this, 'geometrychanged'); } setAppearance(partial: Partial): void { @@ -153,7 +162,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/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 34fa9fc6..9d527cbe 100644 --- a/main/opengeometry-three/src/opengeometry.ts +++ b/main/opengeometry-three/src/opengeometry.ts @@ -17,7 +17,10 @@ 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'; import { motionHint, overrideChanged } from './rendering/lod/lod-controller'; import { createRuntimeOnce } from './runtime/create-runtime'; import { on } from './runtime/event-bus'; @@ -36,6 +39,9 @@ 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 hostGroup(bodies: readonly Body[]): HostGroup { return new HostGroup(bodies); } static settled(options: { deflection?: number } = {}): Promise<{ failed: { ogId: string; error: unknown }[] }> { return settled(options); } @@ -55,6 +61,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/geometry/geometry-scheduler.ts b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts index 00ef8409..eaa9d131 100644 --- a/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts +++ b/main/opengeometry-three/src/rendering/geometry/geometry-scheduler.ts @@ -8,10 +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 { - clearError, displayedBodies, isDisplayed, markFailed, recordError, retryBucket, shapePriority, stampDisplayed, - wantedKey, -} from '../lod/lod-controller'; +import { displayedBodies, isDisplayed, stampDisplayed } from '../lod/displayed'; +import { shown } from '../lod/host-drawn'; +import { queueGrouped } from '../lod/host-groups'; +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'; @@ -26,7 +26,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); } @@ -36,39 +36,56 @@ 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, 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', 'OpenGeometry.flush', `${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 { 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'); } } @@ -83,7 +100,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)); @@ -91,11 +109,15 @@ 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); + queueGrouped(state, body); } 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 { @@ -131,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) }; @@ -231,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/host-group.ts b/main/opengeometry-three/src/rendering/geometry/host-group.ts new file mode 100644 index 00000000..3810957b --- /dev/null +++ b/main/opengeometry-three/src/rendering/geometry/host-group.ts @@ -0,0 +1,42 @@ +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: 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 { + 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; + 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 7ec9bef6..60cffa4a 100644 --- a/main/opengeometry-three/src/rendering/geometry/render-pass.ts +++ b/main/opengeometry-three/src/rendering/geometry/render-pass.ts @@ -1,27 +1,76 @@ import type { Body } from '../../bodies/body'; +import { OGError } from '../../errors'; import { currentRuntime, runtime, type Runtime } from '../../runtime/runtime-state'; -import { displayedBodies, evaluatePass, stampDisplayed } from '../lod/lod-controller'; -import { ensureGeometry, flush } from './geometry-scheduler'; +import { handToHost, shown } from '../lod/host-drawn'; +import { displayedBodies, isDisplayed, stampDisplayed, wakeDisplayed } from '../lod/displayed'; +import { takeWaiting } from '../lod/host-groups'; +import { evaluatePass } from '../lod/lod-controller'; +import { checkedBodies, ensureGeometry, flush } from './geometry-scheduler'; export function noteDisplayed(body: Body): void { const state = runtime(); - if (!state.renderPassActive) { - state.renderPassActive = true; - queueMicrotask(() => { endPass(state); }); - } + const starting = beginPass(state); stampDisplayed(state, body); - flush(); + 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 (!shown(state, body) || (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); +} + +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); +} + +export 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 = new Set(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: 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); +} + export function refreshDisplayed(state: Runtime): void { for (const body of displayedBodies(state)) ensureGeometry(body); } 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/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/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 new file mode 100644 index 00000000..7095ea64 --- /dev/null +++ b/main/opengeometry-three/src/rendering/lod/host-drawn.ts @@ -0,0 +1,32 @@ +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); + body.visible = false; + } else if (!hostDrawn && logical !== undefined) { + state.hostDrawn.delete(body); + body.visible = logical; + } +} 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..28b4c3dc --- /dev/null +++ b/main/opengeometry-three/src/rendering/lod/host-groups.ts @@ -0,0 +1,122 @@ +import type { Body } from '../../bodies/body'; +import type { Runtime } from '../../runtime/runtime-state'; + +export type GroupState = { pass: number; members: Set }; + +export type HostGroups = { + live: Set; + of: WeakMap; + woken: Set; + pending: Set; + drawn: boolean; + revision: number; + dropped: FinalizationRegistry; +}; + +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 { + 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)) 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 = 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); +} + +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 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 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 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); + 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 d24b5a62..ed18746d 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'; @@ -6,13 +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 { 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; @@ -28,6 +30,8 @@ type ShapeLod = { export type LodState = { shapes: Map; camera: Float64Array; + frames: WeakMap; + bufferSize: THREE.Vector2; initialised: boolean; lastMotion: number; lastSchedule: number; @@ -36,42 +40,36 @@ 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), 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, drawn: new Set(), + walked: new WeakMap(), refresh, }; } -export function isDisplayed(state: Runtime, body: Body): boolean { - return body.visible && (state.displayed.get(body) ?? -Infinity) >= state.pass - 1; -} - -export function displayedBodies(state: Runtime): Body[] { - return [...state.displayed.keys()].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; - state.displayed.set(body, state.pass); -} - 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; @@ -98,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; @@ -121,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( @@ -202,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; @@ -225,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); } @@ -273,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 c8462181..5efe950f 100644 --- a/main/opengeometry-three/src/runtime/create-runtime.ts +++ b/main/opengeometry-three/src/runtime/create-runtime.ts @@ -4,13 +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, 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 }; @@ -46,7 +47,9 @@ 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(), + 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/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 daeb765f..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'; @@ -25,8 +26,11 @@ export type Runtime = { revision: bigint; readyShapes: Set; renderPassActive: boolean; + graphMoved: boolean; 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..1df0a108 100644 --- a/main/opengeometry-three/src/testing/internals.ts +++ b/main/opengeometry-three/src/testing/internals.ts @@ -1,4 +1,6 @@ import { OGError } from '../errors'; +import type { Body } from '../bodies/body'; +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'; @@ -16,6 +18,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/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/displayed-drawn.test.mjs b/main/opengeometry-three/tests/node/displayed-drawn.test.mjs new file mode 100644 index 00000000..1143c3f7 --- /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, 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); +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 = 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(); + } 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(); + } +}); diff --git a/main/opengeometry-three/tests/node/flush.test.mjs b/main/opengeometry-three/tests/node/flush.test.mjs index 405a6320..8e2bfba6 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); + Reflect.deleteProperty(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); + } +}); 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(); + } +}); 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..53f27425 --- /dev/null +++ b/main/opengeometry-three/tests/node/host-groups.test.mjs @@ -0,0 +1,370 @@ +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'; +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(); + OpenGeometry.reset(); + CAMERA.position.set(0, 0, 10); + 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; +} + +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 { + 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 = 0; + observe(CAMERA); + 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(); + } + const evaluated = state.lod.lastSchedule > 0; + return { lookups: logs.map((log) => log.calls), installs, active: state.renderPassActive, evaluated }; + } 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'); + 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 () => { + 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(); + } +}); + +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/opengeometry-three/tests/node/lod.test.mjs b/main/opengeometry-three/tests/node/lod.test.mjs index 361cbbe9..be54d929 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++; + Reflect.apply(OpenGeometry.observeCamera, OpenGeometry, [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(); + Reflect.apply(OpenGeometry.observeCamera, OpenGeometry, [renderer, ortho(P)]); + OpenGeometry.reset(); + assert.equal(TESTING.runtime().lod.lastSeen, undefined); + Reflect.apply(OpenGeometry.observeCamera, OpenGeometry, [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/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); +}); 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(); } 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)), + } +} 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;