From 9d1af0e1d4a78251c03431e49267d90448fd8aa9 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 8 Sep 2026 14:07:22 -0400 Subject: [PATCH 1/8] perf(nodes): batch ceiling undersides and slab bodies --- packages/nodes/src/ceiling/materials.ts | 1 - .../nodes/src/shared/node-batch/candidates.ts | 35 +- .../src/shared/node-batch/node-batch.test.ts | 339 ++++++++++++++++++ .../shared/node-batch/source-systems.test.ts | 139 +++++++ packages/nodes/src/shared/node-batch/store.ts | 19 +- .../nodes/src/shared/node-batch/system.tsx | 54 ++- packages/nodes/src/shared/node-batch/types.ts | 23 +- packages/nodes/src/shared/slot-paint.ts | 54 ++- packages/nodes/src/slab/geometry.ts | 1 + packages/viewer/src/lib/materials.ts | 4 +- .../src/systems/ceiling/ceiling-system.tsx | 2 +- wiki/architecture/materials-and-themes.md | 11 +- wiki/architecture/node-definitions.md | 2 +- wiki/architecture/systems.md | 12 + 14 files changed, 657 insertions(+), 39 deletions(-) create mode 100644 packages/nodes/src/shared/node-batch/node-batch.test.ts create mode 100644 packages/nodes/src/shared/node-batch/source-systems.test.ts diff --git a/packages/nodes/src/ceiling/materials.ts b/packages/nodes/src/ceiling/materials.ts index a6e4e590ed..60512382c1 100644 --- a/packages/nodes/src/ceiling/materials.ts +++ b/packages/nodes/src/ceiling/materials.ts @@ -42,7 +42,6 @@ function createCeilingMaterials(color = '#999999'): CeilingMaterials { const bottomMaterial = new MeshBasicNodeMaterial({ color, - transparent: true, side: BackSide, }) diff --git a/packages/nodes/src/shared/node-batch/candidates.ts b/packages/nodes/src/shared/node-batch/candidates.ts index 60b5c0c0fc..1f170da51c 100644 --- a/packages/nodes/src/shared/node-batch/candidates.ts +++ b/packages/nodes/src/shared/node-batch/candidates.ts @@ -5,10 +5,12 @@ import { sceneRegistry, useInteractive, useLiveNodeOverrides, + useLiveTransforms, useScene, } from '@pascal-app/core' import { hideFromScene, SCENE_LAYER, showInScene, useViewer } from '@pascal-app/viewer' import { type Material, Matrix4, type Mesh, type Object3D } from 'three' +import { isSlotPaintPreviewActive } from '../slot-paint' import type { BatchCandidate, BatchEntry } from './types' /** @@ -18,7 +20,14 @@ import type { BatchCandidate, BatchEntry } from './types' */ /** Kinds the batch system manages. Walls keep their merged-geometry batch. */ -export const BATCH_KINDS: ReadonlySet = new Set(['item', 'column', 'door', 'window']) +export const BATCH_KINDS: ReadonlySet = new Set([ + 'item', + 'column', + 'door', + 'window', + 'ceiling', + 'slab', +]) const rootInverse = new Matrix4() @@ -36,7 +45,12 @@ const hiddenMeshesByNode = new Map() function collectMeshes(object: Object3D, out: Mesh[], hostedRoots: ReadonlySet): void { if (object.visible === false || hostedRoots.has(object)) return const mesh = object as Mesh - if (mesh.isMesh && mesh.name !== 'cutout' && mesh.layers.isEnabled(SCENE_LAYER)) { + if ( + mesh.isMesh && + mesh.name !== 'cutout' && + mesh.name !== 'ceiling-grid' && + mesh.layers.isEnabled(SCENE_LAYER) + ) { out.push(mesh) } for (const child of object.children) collectMeshes(child, out, hostedRoots) @@ -44,7 +58,7 @@ function collectMeshes(object: Object3D, out: Mesh[], hostedRoots: ReadonlySet): string | null { const parent = node.parentId ? nodes[node.parentId] : undefined if (!parent) return null - if (node.type === 'item' || node.type === 'column') { + if ( + node.type === 'item' || + node.type === 'column' || + node.type === 'ceiling' || + node.type === 'slab' + ) { return parent.type === 'level' ? (parent.id as string) : null } // door / window: host wall → its level. A hidden wall hides its openings @@ -90,7 +109,7 @@ export function collectBatchCandidate(nodeId: string): BatchCandidate | null { const levelId = resolveLevelId(node, nodes) if (!levelId) return null - if (isExcluded(node)) return null + if (isExcluded(node) || isSlotPaintPreviewActive(nodeId)) return null const group = sceneRegistry.nodes.get(nodeId) if (!group) return null @@ -111,7 +130,7 @@ export function collectBatchCandidate(nodeId: string): BatchCandidate | null { // means an in-flight gesture: transforms are moving under our feet and the // commit's dirty mark has not landed yet. const overrides = useLiveNodeOverrides.getState() - if (overrides.get(nodeId as AnyNodeId)) return null + if (overrides.get(nodeId as AnyNodeId) || useLiveTransforms.getState().get(nodeId)) return null if ( (node.type === 'door' || node.type === 'window') && node.parentId && @@ -158,6 +177,8 @@ export function collectBatchCandidate(nodeId: string): BatchCandidate | null { mesh, geometry: mesh.geometry, material, + castShadow: mesh.castShadow, + receiveShadow: mesh.receiveShadow, matrixInLevel: new Matrix4().multiplyMatrices(rootInverse, mesh.matrixWorld), }) } @@ -208,6 +229,7 @@ export function collectTintedNodes(nodeIds: ReadonlySet): Set { const tinted = new Set() for (const id of viewer.selection.selectedIds) if (nodeIds.has(id)) tinted.add(id) for (const id of viewer.previewSelectedIds) if (nodeIds.has(id)) tinted.add(id) + for (const id of viewer.externalSelectedIds) if (nodeIds.has(id)) tinted.add(id) const hovered = viewer.hoveredId if (hovered && nodeIds.has(hovered)) tinted.add(hovered) @@ -216,6 +238,7 @@ export function collectTintedNodes(nodeIds: ReadonlySet): Set { const wallLit = new Set() for (const id of viewer.selection.selectedIds) wallLit.add(id) for (const id of viewer.previewSelectedIds) wallLit.add(id) + for (const id of viewer.externalSelectedIds) wallLit.add(id) if (hovered) wallLit.add(hovered) for (const id of nodeIds) { if (tinted.has(id)) continue diff --git a/packages/nodes/src/shared/node-batch/node-batch.test.ts b/packages/nodes/src/shared/node-batch/node-batch.test.ts new file mode 100644 index 0000000000..565817d840 --- /dev/null +++ b/packages/nodes/src/shared/node-batch/node-batch.test.ts @@ -0,0 +1,339 @@ +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' +import { + type AnyNode, + itemClipRegistry, + sceneRegistry, + useInteractive, + useLiveNodeOverrides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { SCENE_LAYER, useViewer } from '@pascal-app/viewer' +import { + BackSide, + type BatchedMesh, + BoxGeometry, + Group, + Matrix4, + Mesh, + MeshBasicMaterial, +} from 'three' +import { applyShadowOnly, clearShadowOnly } from '../../../../viewer/src/lib/shadow-only' +import { getCeilingMaterials } from '../../ceiling/materials' +import { ceilingPaint } from '../../ceiling/paint' +import { createSlotPaintCapability, isSlotPaintPreviewActive } from '../slot-paint' +import { collectBatchCandidate, collectTintedNodes } from './candidates' +import { NodeBatchStore } from './store' +import { + captureChangedNodes, + resetNodeBatchState, + runBatchFrame, + subscribeBatchInteractions, +} from './system' + +let now = 0 +let restoreClock: () => void +let unsubscribe: () => void +const wakeRef: { current: ReturnType | null } = { current: null } +const originalViewer = useViewer.getState() +const stores: NodeBatchStore[] = [] +const restores: Array<() => void> = [] + +beforeEach(() => { + now = 0 + const clock = spyOn(performance, 'now').mockImplementation(() => now) + restoreClock = () => clock.mockRestore() + sceneRegistry.clear() + useScene.setState({ nodes: {}, dirtyNodes: new Set(), materials: {}, rootNodeIds: [] } as never) + useViewer.setState({ + selection: { ...originalViewer.selection, selectedIds: [], levelId: null }, + previewSelectedIds: [], + externalSelectedIds: [], + hoveredId: null, + levelMode: 'stacked', + } as never) + unsubscribe = subscribeBatchInteractions(() => {}) +}) + +afterEach(() => { + for (const restore of restores.splice(0).reverse()) restore() + unsubscribe() + useLiveTransforms.getState().clearAll() + useLiveNodeOverrides.getState().clearAll() + useInteractive.setState({ doorAnimations: {}, windowAnimations: {} }) + resetNodeBatchState() + for (const store of stores.splice(0)) store.disposeAll() + if (wakeRef.current) clearTimeout(wakeRef.current) + wakeRef.current = null + sceneRegistry.clear() + useScene.setState({ nodes: {}, dirtyNodes: new Set(), rootNodeIds: [] } as never) + useViewer.setState(originalViewer) + restoreClock() +}) + +function frame() { + runBatchFrame(() => {}, wakeRef) +} +function settle() { + frame() + now += 181 + frame() +} + +function setup(kind = 'ceiling', count = 4) { + const root = new Group() + sceneRegistry.nodes.set('level_test', root) + sceneRegistry.byType.level.add('level_test') + const material = new MeshBasicMaterial({ side: BackSide }) + const meshes = Array.from({ length: count }, (_, index) => { + const id = `${kind}_${index}` + const mesh = new Mesh(new BoxGeometry(), material) + mesh.userData.itemModelSettled = true + root.add(mesh) + sceneRegistry.nodes.set(id, mesh) + sceneRegistry.byType[kind]!.add(id) + return mesh + }) + useScene.setState({ + nodes: { + level_test: { id: 'level_test', type: 'level', children: [] }, + ...Object.fromEntries( + meshes.map((_, index) => { + const id = `${kind}_${index}` + return [id, { id, type: kind, parentId: 'level_test', visible: true, children: [] }] + }), + ), + }, + } as never) + return { root, meshes, material } +} + +function candidate(id: string) { + const result = collectBatchCandidate(id) + if (!result) throw new Error(`Expected candidate: ${id}`) + return result +} + +function batches(root: Group) { + return root.children.filter((child) => child.name === 'item-batch') as BatchedMesh[] +} + +test('collects the ceiling root mesh, pruning hosted items and the grid even when opaque', () => { + const { meshes } = setup() + const mesh = meshes[0]! + const grid = new Mesh(new BoxGeometry(), new MeshBasicMaterial()) + grid.name = 'ceiling-grid' + const hosted = new Mesh(new BoxGeometry(), new MeshBasicMaterial()) + mesh.add(grid, hosted) + sceneRegistry.nodes.set('item_hosted', hosted) + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + ceiling_0: { ...useScene.getState().nodes.ceiling_0, children: ['item_hosted'] }, + item_hosted: { id: 'item_hosted', type: 'item', parentId: 'ceiling_0' }, + }, + } as never) + expect(candidate('ceiling_0').entries.map((entry) => entry.mesh)).toEqual([mesh]) + expect(collectBatchCandidate('item_hosted')).toBeNull() + expect(getCeilingMaterials().bottomMaterial.transparent).toBe(false) + expect(getCeilingMaterials().bottomMaterial.side).toBe(BackSide) + expect(getCeilingMaterials().topMaterial.transparent).toBe(true) + expect(getCeilingMaterials().topMaterial.depthWrite).toBe(false) +}) + +test('separates both shadow flags and preserves them through capacity growth', () => { + const { root, meshes } = setup('ceiling', 12) + for (let i = 0; i < 3; i++) { + meshes[i]!.castShadow = i === 1 + meshes[i]!.receiveShadow = i === 2 + } + const store = new NodeBatchStore(() => root) + stores.push(store) + store.join([candidate('ceiling_0'), candidate('ceiling_1'), candidate('ceiling_2')], 1) + expect(batches(root).map((batch) => [batch.castShadow, batch.receiveShadow])).toEqual([ + [false, false], + [true, false], + [false, true], + ]) + store.join( + meshes.slice(3).map((_, index) => candidate(`ceiling_${index + 3}`)), + 1, + ) + expect(batches(root)).toHaveLength(3) + expect( + batches(root).find((batch) => !batch.castShadow && !batch.receiveShadow)?.instanceCount, + ).toBe(10) + expect(batches(root).every((batch) => batch.userData.pascalExport === 'strip')).toBe(true) +}) + +test.each([ + 'ceiling', + 'slab', + 'item', +])('unselected %s live move releases immediately and rejoins on clear', (kind) => { + const { root, meshes } = setup(kind) + settle() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + useLiveTransforms.getState().set(`${kind}_0`, { position: [5, 0, 2], rotation: 0 }) + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + meshes[0]!.position.set(5, 0, 2) + settle() + expect(collectBatchCandidate(`${kind}_0`)).toBeNull() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + useLiveTransforms.getState().clear(`${kind}_0`) + settle() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + const matrix = new Matrix4() + const batch = batches(root)[0]! + const translations = [] + for (let i = 0; i < batch.instanceCount; i++) { + if (batch.getMatrixAt(i, matrix)) translations.push(matrix.elements[12]) + } + expect(translations).toContain(5) +}) + +test('paint fan-out releases secondary targets before swapping and never settles preview material', () => { + const { root, meshes, material } = setup() + settle() + useViewer.setState({ hoveredId: 'ceiling_0' } as never) + for (const id of ['ceiling_0', 'ceiling_1']) { + const restore = ceilingPaint.applyPreview({ + node: useScene.getState().nodes[id]!, + root: sceneRegistry.nodes.get(id)!, + role: 'surface', + material: { properties: { color: '#ff0000' } } as never, + materialPreset: undefined, + })! + restores.push(restore) + } + expect(meshes[1]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + expect(meshes[1]!.material).not.toBe(material) + expect(isSlotPaintPreviewActive('ceiling_1')).toBe(true) + settle() + expect(batches(root).every((batch) => batch.material === material)).toBe(true) + expect(collectBatchCandidate('ceiling_1')).toBeNull() + for (const restore of restores.splice(0).reverse()) restore() + expect(meshes[1]!.material).toBe(material) + settle() + expect(meshes[1]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + expect(batches(root).every((batch) => batch.material === material)).toBe(true) +}) + +test('overlapping preview holds end only after the final restore; failed previews release their hold', () => { + setup() + const args = { + node: useScene.getState().nodes.ceiling_0!, + root: sceneRegistry.nodes.get('ceiling_0')!, + role: 'surface', + material: undefined, + materialPreset: undefined, + } + const paint = createSlotPaintCapability({ + resolveRole: () => 'surface', + applyPreview: () => () => {}, + }) + const first = paint.applyPreview(args)! + const second = paint.applyPreview(args)! + first() + first() + expect(isSlotPaintPreviewActive('ceiling_0')).toBe(true) + second() + expect(isSlotPaintPreviewActive('ceiling_0')).toBe(false) + const failed = createSlotPaintCapability({ + resolveRole: () => 'surface', + applyPreview: () => null, + }) + expect(failed.applyPreview(args)).toBeNull() + expect(isSlotPaintPreviewActive('ceiling_0')).toBe(false) +}) + +test.each([ + 'mode', + 'selected-level', +])('shadow-only candidates are re-offered on %s restoration', (change) => { + const { root, meshes } = setup() + useViewer.setState({ + levelMode: 'solo', + selection: { ...useViewer.getState().selection, levelId: 'level_other' }, + } as never) + applyShadowOnly(root) + settle() + expect(batches(root)).toHaveLength(0) + clearShadowOnly(root) + if (change === 'mode') useViewer.setState({ levelMode: 'stacked' }) + else + useViewer.setState({ + selection: { ...useViewer.getState().selection, levelId: 'level_test' }, + } as never) + settle() + expect(batches(root)).toHaveLength(1) + expect(meshes.every((mesh) => !mesh.layers.isEnabled(SCENE_LAYER))).toBe(true) +}) + +test('external selection releases sources for outline masks and reoffers after clearing', () => { + const { meshes } = setup() + settle() + useViewer.setState({ externalSelectedIds: ['ceiling_1'] } as never) + expect(collectTintedNodes(new Set(['ceiling_1']))).toEqual(new Set(['ceiling_1'])) + frame() + expect(meshes[1]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + useViewer.setState({ externalSelectedIds: [] }) + settle() + expect(meshes[1]!.layers.isEnabled(SCENE_LAYER)).toBe(false) +}) + +test('items retain loading, animation, transparency, hidden-hitbox and dirty-rejoin guards', () => { + const { meshes } = setup('item') + meshes[0]!.userData.itemModelSettled = false + expect(collectBatchCandidate('item_0')).toBeNull() + meshes[0]!.userData.itemModelSettled = true + meshes[0]!.userData.itemHasAnimations = true + expect(collectBatchCandidate('item_0')).toBeNull() + meshes[0]!.userData.itemHasAnimations = false + meshes[0]!.material = new MeshBasicMaterial({ transparent: true }) + expect(collectBatchCandidate('item_0')).toBeNull() + meshes[0]!.material = new MeshBasicMaterial({ visible: false }) + expect(collectBatchCandidate('item_0')).toBeNull() + meshes[0]!.material = meshes[1]!.material + settle() + useScene.getState().dirtyNodes.add('item_0' as never) + captureChangedNodes() + useScene.getState().dirtyNodes.clear() + frame() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + settle() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + useViewer.setState({ hoveredId: 'item_0' } as never) + frame() + itemClipRegistry.set('item_0', {} as never) + expect(collectBatchCandidate('item_0')).toBeNull() + itemClipRegistry.delete('item_0') +}) + +test.each([ + 'door', + 'window', +])('%s retains host tint/override and active-animation exclusions', (kind) => { + setup(kind) + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + wall_host: { + id: 'wall_host', + type: 'wall', + parentId: 'level_test', + visible: true, + children: [`${kind}_0`], + }, + [`${kind}_0`]: { ...useScene.getState().nodes[`${kind}_0`], parentId: 'wall_host' }, + }, + } as never) + expect(candidate(`${kind}_0`).levelId).toBe('level_test') + useViewer.setState({ externalSelectedIds: ['wall_host'] } as never) + expect(collectTintedNodes(new Set([`${kind}_0`]))).toEqual(new Set([`${kind}_0`])) + useLiveNodeOverrides.getState().set('wall_host', { visible: true } as Partial) + expect(collectBatchCandidate(`${kind}_0`)).toBeNull() + useLiveNodeOverrides.getState().clearAll() + useInteractive.setState({ [`${kind}Animations`]: { [`${kind}_0`]: {} } } as never) + expect(collectBatchCandidate(`${kind}_0`)).toBeNull() +}) diff --git a/packages/nodes/src/shared/node-batch/source-systems.test.ts b/packages/nodes/src/shared/node-batch/source-systems.test.ts new file mode 100644 index 0000000000..5e29fe0b13 --- /dev/null +++ b/packages/nodes/src/shared/node-batch/source-systems.test.ts @@ -0,0 +1,139 @@ +import { expect, test } from 'bun:test' +import { resolve } from 'node:path' + +// Isolate module wiring: exercise changed viewer sources without rebuilding live dists +// or leaking Bun's process-global module mocks into the randomized nodes suite. +function runSourceTest(body: string) { + const result = Bun.spawnSync( + [ + process.execPath, + '-e', + ` + import assert from 'node:assert/strict' + import { mock } from 'bun:test' + import * as core from '@pascal-app/core' + import * as viewer from '@pascal-app/viewer' + import { Group, Mesh, MeshBasicMaterial } from 'three' + ${body} + `, + ], + { cwd: resolve(import.meta.dir, '../../../../..'), stdout: 'pipe', stderr: 'pipe' }, + ) + expect({ code: result.exitCode, stderr: result.stderr.toString() }).toEqual({ + code: 0, + stderr: '', + }) +} + +test('real slab top/side/skirt collection, shared defaults, transparent overrides and cache ownership', () => { + runSourceTest(` + const sourceMaterials = await import('./packages/viewer/src/lib/materials.ts') + mock.module('@pascal-app/viewer', () => ({ ...viewer, resolveSlotDefaultMaterial: sourceMaterials.resolveSlotDefaultMaterial })) + const { buildSlabGeometry } = await import('./packages/nodes/src/slab/geometry.ts') + const { collectBatchCandidate } = await import('./packages/nodes/src/shared/node-batch/candidates.ts') + const { disposeObject3DResources } = await import('./packages/viewer/src/lib/dispose-object3d.ts') + const site = core.SiteNode.parse({ id: 'site_test', children: ['building_test'] }) + const building = core.BuildingNode.parse({ id: 'building_test', parentId: site.id, children: ['level_test'] }) + const level = core.LevelNode.parse({ id: 'level_test', parentId: building.id, level: 0, height: 2.5 }) + const nodes = { [site.id]: site, [building.id]: building, [level.id]: level } + const ctx = { parent: level, children: [], siblings: [], resolve: (id) => nodes[id] } + const slab = core.SlabNode.parse({ id: 'slab_test', parentId: level.id, elevation: 0.8, thickness: 0.2, fillToTerrain: true, polygon: [[0,0],[2,0],[2,2],[0,2]] }) + const first = buildSlabGeometry(slab, ctx, 'solid') + const second = buildSlabGeometry({ ...slab, id: 'slab_second' }, ctx, 'solid') + assert.equal(first.children.length, 3) + assert.deepEqual(first.children.map((mesh) => mesh.userData.slotId), ['surface', 'side', 'side']) + assert.equal(first.children[1].material, second.children[1].material) + assert.equal(first.children[1].material, first.children[2].material) + assert.notEqual(first.children[1].geometry, second.children[1].geometry) + const root = new Group() + root.add(first) + core.sceneRegistry.nodes.set(level.id, root) + core.sceneRegistry.nodes.set(slab.id, first) + core.useScene.setState({ nodes: { ...nodes, [slab.id]: slab } }) + const entries = collectBatchCandidate(slab.id).entries + assert.equal(entries.length, 3) + assert(entries.every((entry) => entry.castShadow && entry.receiveShadow)) + const override = core.SlabNode.parse({ ...slab, slots: { side: 'scene:sm_transparent' } }) + const painted = buildSlabGeometry(override, { ...ctx, materials: { sm_transparent: { id: 'sm_transparent', name: 'Glass', material: { properties: { color: '#abcdef', opacity: 0.3, transparent: true } } } } }, 'solid') + root.add(painted) + core.sceneRegistry.nodes.set(slab.id, painted) + const paintedEntries = collectBatchCandidate(slab.id).entries + assert.equal(paintedEntries.length, 1) + assert.equal(paintedEntries[0].mesh.userData.slotId, 'surface') + const side = first.children[1].material + let disposed = 0 + side.addEventListener('dispose', () => disposed++) + disposeObject3DResources(first) + assert.equal(disposed, 0) + const legacy = { ...slab, material: { properties: { color: '#123456' } } } + const legacyFirst = buildSlabGeometry(legacy, ctx, 'solid') + const legacySecond = buildSlabGeometry(legacy, ctx, 'solid') + const top = legacyFirst.children[0].material + assert.equal(top, legacySecond.children[0].material) + top.addEventListener('dispose', () => disposed++) + disposeObject3DResources(legacyFirst) + assert.equal(disposed, 0) + assert.equal(top.transparent, false) + assert.notEqual(sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'solid', 0.8), sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'rendered', 0.8)) + assert.notEqual(sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'rendered', 0.8), sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'rendered', 0.4)) + `) +}) + +test('priority-1 dirty snapshot sees the priority-2 ceiling rebuild and batches replacement geometry at 5', () => { + runSourceTest(String.raw` + const scene = core.useScene + const selectorHook = Object.assign((selector) => selector(scene.getState()), scene) + mock.module('@pascal-app/core', () => ({ ...core, useScene: selectorHook })) + const fiber = await import('@react-three/fiber') + const callbacks = [] + mock.module('@react-three/fiber', () => ({ ...fiber, useFrame: (callback, priority = 0) => callbacks.push({ callback, priority }) })) + const { CeilingSystem, generateCeilingGeometry } = await import('./packages/viewer/src/systems/ceiling/ceiling-system.tsx') + const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import('./packages/nodes/src/shared/node-batch/system.tsx') + const batchSource = await Bun.file('./packages/nodes/src/shared/node-batch/system.tsx').text() + assert.match(batchSource, /useFrame\(captureChangedNodes, 1\)/) + assert.match(batchSource, /useFrame\(\(\) => runBatchFrame\(invalidate, wakeRef\), 5\)/) + const geometrySource = await Bun.file('./packages/viewer/src/systems/geometry/geometry-system.tsx').text() + assert.match(geometrySource, /}, 2\)/) + let now = 0 + performance.now = () => now + const root = new Group() + const material = new MeshBasicMaterial() + const nodes = { level_test: { id: 'level_test', type: 'level', height: 3, children: [] } } + const meshes = [] + core.sceneRegistry.nodes.set('level_test', root) + core.sceneRegistry.byType.level.add('level_test') + for (let i = 0; i < 4; i++) { + const node = core.CeilingNode.parse({ id: 'ceiling_' + i, parentId: 'level_test', polygon: [[0,0],[2,0],[2,2],[0,2]], height: 3 }) + nodes[node.id] = node + const mesh = new Mesh(generateCeilingGeometry(node), material) + root.add(mesh) + meshes.push(mesh) + core.sceneRegistry.nodes.set(node.id, mesh) + core.sceneRegistry.byType.ceiling.add(node.id) + } + scene.setState({ nodes, dirtyNodes: new Set() }) + viewer.useViewer.setState({ externalSelectedIds: [], previewSelectedIds: [], hoveredId: null, selection: { ...viewer.useViewer.getState().selection, selectedIds: [], levelId: null } }) + const wakeRef = { current: null } + const frame = () => runBatchFrame(() => {}, wakeRef) + frame(); now = 181; frame() + assert.equal(meshes[0].layers.isEnabled(viewer.SCENE_LAYER), false) + const oldGeometry = meshes[0].geometry + nodes.ceiling_0.polygon = [[0,0],[8,0],[8,2],[0,2]] + scene.getState().markDirty('ceiling_0') + CeilingSystem() + assert.equal(callbacks.length, 1) + assert.equal(callbacks[0].priority, 2) + const pipeline = [{ priority: 1, callback: captureChangedNodes }, callbacks[0], { priority: 5, callback: frame }].sort((a,b) => a.priority - b.priority) + assert.deepEqual(pipeline.map((pass) => pass.priority), [1, 2, 5]) + for (const pass of pipeline) pass.callback() + assert.equal(scene.getState().dirtyNodes.has('ceiling_0'), false) + assert.notEqual(meshes[0].geometry, oldGeometry) + assert.equal(meshes[0].layers.isEnabled(viewer.SCENE_LAYER), true) + now += 181; frame() + assert.equal(meshes[0].layers.isEnabled(viewer.SCENE_LAYER), false) + const packed = root.children.filter((child) => child.name === 'item-batch') + assert(packed.some((batch) => Array.from(batch.geometry.attributes.position.array).includes(8))) + resetNodeBatchState() + if (wakeRef.current) clearTimeout(wakeRef.current) + `) +}) diff --git a/packages/nodes/src/shared/node-batch/store.ts b/packages/nodes/src/shared/node-batch/store.ts index 96f7a734be..6d49f36871 100644 --- a/packages/nodes/src/shared/node-batch/store.ts +++ b/packages/nodes/src/shared/node-batch/store.ts @@ -9,9 +9,9 @@ import type { /** * BatchedMesh container for node batching — see types.ts for the - * architecture invariants. One BatchedMesh per `(levelId, material.uuid)`, - * parented under the level root; membership changes are instance - * adds/deletes, and only a capacity overflow rebuilds a batch. + * architecture invariants. One BatchedMesh per level, material, attribute + * layout and shadow flags, parented under the level root; membership changes + * are instance adds/deletes, and only a capacity overflow rebuilds a batch. */ /** Batch meshes are draw-only; sources keep every raycast (wall-batch rule). */ @@ -67,8 +67,8 @@ function attributeSignature(geometry: BufferGeometry): string { return `${Object.keys(geometry.attributes).sort().join(',')}|${geometry.index ? 'i' : 'n'}` } -const batchKey = (levelId: string, materialUuid: string, signature: string) => - `${levelId}|${materialUuid}|${signature}` +const batchKey = (entry: BatchEntry) => + `${entry.levelId}|${entry.material.uuid}|${attributeSignature(entry.geometry)}|${entry.castShadow}|${entry.receiveShadow}` function vertexCount(geometry: BufferGeometry): number { return geometry.attributes.position?.count ?? 0 @@ -98,7 +98,7 @@ export class NodeBatchStore implements NodeBatchStoreApi { for (const candidate of candidates) { for (const entry of candidate.entries) { if (vertexCount(entry.geometry) === 0) continue - const key = batchKey(entry.levelId, entry.material.uuid, attributeSignature(entry.geometry)) + const key = batchKey(entry) const bucket = byBatch.get(key) if (bucket) bucket.push(entry) else byBatch.set(key, [entry]) @@ -128,6 +128,7 @@ export class NodeBatchStore implements NodeBatchStoreApi { record = this.createBatch( entries[0]!.levelId, entries[0]!.material, + entries[0]!, root, entries.length, addedVertices, @@ -254,6 +255,7 @@ export class NodeBatchStore implements NodeBatchStoreApi { private createBatch( levelId: string, material: Material, + shadows: Pick, root: Object3D, instanceCount: number, vertices: number, @@ -274,8 +276,8 @@ export class NodeBatchStore implements NodeBatchStoreApi { // meshes are back on the scene layer for the export clone — this marker // is the backstop for any capture path that skips the emit. batched.userData.pascalExport = 'strip' - batched.castShadow = true - batched.receiveShadow = true + batched.castShadow = shadows.castShadow + batched.receiveShadow = shadows.receiveShadow batched.perObjectFrustumCulled = true // Whole-container culling would use a bounding sphere computed at first // cull — instances joining farther out later could vanish with the whole @@ -315,6 +317,7 @@ export class NodeBatchStore implements NodeBatchStoreApi { const next = this.createBatch( old.levelId, old.material, + old.batched, root, survivors.length + extraInstances, old.used.vertices + extraVertices, diff --git a/packages/nodes/src/shared/node-batch/system.tsx b/packages/nodes/src/shared/node-batch/system.tsx index 7bcde0141f..5a25de6db3 100644 --- a/packages/nodes/src/shared/node-batch/system.tsx +++ b/packages/nodes/src/shared/node-batch/system.tsx @@ -6,12 +6,14 @@ import { sceneRegistry, useInteractive, useLiveNodeOverrides, + useLiveTransforms, useScene, } from '@pascal-app/core' import { isIsolationActive, publishPerfBatchStats, useViewer } from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import type { Object3D } from 'three' +import { isSlotPaintPreviewActive, subscribeSlotPaintPreviews } from '../slot-paint' import { BATCH_KINDS, collectBatchCandidate, @@ -67,6 +69,8 @@ let knownNodeIds: ReadonlySet | null = null const waveDebug = { runs: 0, stale: 0, candidates: 0, joined: 0, nullCandidates: 0 } let lastNodeChangeAtMs = 0 let batchingSuspended = false +let lastLevelMode: string | undefined +let lastSelectedLevel: string | null | undefined type AppearanceInputs = { shading: unknown @@ -107,7 +111,8 @@ function appearanceChanged(): boolean { return true } -function resetModuleState() { +export function resetNodeBatchState() { + releaseAll() changedNodes.clear() staleNodes.clear() partialNodes.clear() @@ -115,6 +120,8 @@ function resetModuleState() { knownNodeIds = null lastNodeChangeAtMs = 0 batchingSuspended = false + lastLevelMode = undefined + lastSelectedLevel = undefined lastAppearance.shading = undefined lastAppearance.textures = undefined lastAppearance.colorPreset = undefined @@ -151,7 +158,30 @@ function releaseAll() { revealAllBatchedHolds() } -function captureChangedNodes() { +export function subscribeBatchInteractions(invalidate: () => void): () => void { + const changed = (nodeId: string) => { + const node = useScene.getState().nodes[nodeId as AnyNodeId] + if (!node || !BATCH_KINDS.has(node.type)) return + releaseNode(nodeId) + changedNodes.add(nodeId) + invalidate() + } + const unsubscribeTransforms = useLiveTransforms.subscribe((state, previous) => { + for (const nodeId of state.transforms.keys()) { + if (!previous.transforms.has(nodeId)) changed(nodeId) + } + for (const nodeId of previous.transforms.keys()) { + if (!state.transforms.has(nodeId)) changed(nodeId) + } + }) + const unsubscribePreviews = subscribeSlotPaintPreviews(changed) + return () => { + unsubscribeTransforms() + unsubscribePreviews() + } +} + +export function captureChangedNodes() { const dirty = useScene.getState().dirtyNodes if (dirty.size === 0) return const nodes = useScene.getState().nodes @@ -172,7 +202,7 @@ function captureChangedNodes() { } } -function runBatchFrame( +export function runBatchFrame( invalidate: () => void, wakeRef: { current: ReturnType | null }, ) { @@ -195,6 +225,15 @@ function runBatchFrame( } changedNodes.clear() + const viewer = useViewer.getState() + if (lastLevelMode !== viewer.levelMode || lastSelectedLevel !== viewer.selection.levelId) { + lastLevelMode = viewer.levelMode + lastSelectedLevel = viewer.selection.levelId + // Shadow-only sources were rejected and dropped from the previous join wave. + for (const nodeId of nodeIds) if (!store.has(nodeId)) staleNodes.add(nodeId) + changed = true + } + const tinted = collectTintedNodes(nodeIds) for (const nodeId of tinted) { if (!store.has(nodeId)) continue @@ -316,7 +355,9 @@ function runBatchFrame( if ( tinted.has(nodeId) || dirty.has(nodeId as AnyNodeId) || - overrides.get(nodeId) !== undefined + overrides.get(nodeId) !== undefined || + useLiveTransforms.getState().get(nodeId) !== undefined || + isSlotPaintPreviewActive(nodeId) ) { deferred.add(nodeId) continue @@ -384,6 +425,8 @@ const NodeBatchSystemActive = () => { const invalidate = useThree((state) => state.invalidate) const wakeRef = useRef | null>(null) + useEffect(() => subscribeBatchInteractions(invalidate), [invalidate]) + // Before the consuming systems (priority 2+) clear the marks this frame. useFrame(captureChangedNodes, 1) useFrame(() => runBatchFrame(invalidate, wakeRef), 5) @@ -491,8 +534,7 @@ const NodeBatchSystemActive = () => { useEffect( () => () => { if (wakeRef.current) clearTimeout(wakeRef.current) - releaseAll() - resetModuleState() + resetNodeBatchState() }, [], ) diff --git a/packages/nodes/src/shared/node-batch/types.ts b/packages/nodes/src/shared/node-batch/types.ts index 09e3f7159b..519ab557b6 100644 --- a/packages/nodes/src/shared/node-batch/types.ts +++ b/packages/nodes/src/shared/node-batch/types.ts @@ -5,14 +5,12 @@ import type { BufferGeometry, Material, Matrix4, Mesh, Object3D } from 'three' * * Mirrors the wall-batch architecture (`../../wall/wall-batch-system.tsx`) * with one structural upgrade: the container is a `THREE.BatchedMesh` per - * `(levelId, material, attribute-signature)` instead of merged geometry, so - * membership changes are incremental instance adds/deletes rather than - * buffer resews. + * `(levelId, material, attribute-signature, castShadow, receiveShadow)`; + * membership changes are incremental instance adds/deletes. * - * Batched kinds: items and columns (level-parented), doors and windows - * (wall-hosted, resolved to the host wall's level). Walls keep their own - * merged-geometry batch — their geometry itself changes on edit, which is - * the case BatchedMesh instances cannot express. + * Batched kinds: items, columns, ceilings and slabs (level-parented), doors + * and windows (wall-hosted, resolved to the host wall's level). Walls keep + * their own merged-geometry batch and cutaway lifecycle. * * Invariants every module must respect: * - Source meshes STAY MOUNTED. They are draw-hidden via @@ -21,11 +19,12 @@ import type { BufferGeometry, Material, Matrix4, Mesh, Object3D } from 'three' * are draw-only: `raycast` is a noop, name is `'item-batch'`. * - Batch meshes are parented under the LEVEL ROOT, so level visibility and * isolation cull batches exactly like every other level child. - * - A tinted node (selected, preview-selected or hovered — and for hosted - * openings, one whose host wall is tinted or mid-gesture) is released and - * draws its own meshes. + * - A tinted node (selected, externally selected, preview-selected or hovered) + * is released and draws itself, as do hosted openings whose host wall is + * tinted or mid-gesture. * - Membership follows the scene dirty signal + the node-count tell; a batch - * never chases per-frame transforms. A dirty host wall releases its + * never chases per-frame transforms. Live transforms and slot paint previews + * release sources until they end. A dirty host wall releases its * openings (their level-space transforms move with the wall). * - Doors/windows with an active animation record are excluded while it * runs; the completion dirty mark re-joins them at the settled pose. @@ -43,6 +42,8 @@ export type BatchEntry = { * part of the batch key. Array-material meshes are not batchable. */ material: Material + castShadow: boolean + receiveShadow: boolean /** Source mesh world matrix expressed in level-root space, captured at join. */ matrixInLevel: Matrix4 } diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index afd0bf29e9..11e70e8609 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -29,6 +29,34 @@ import { type Material, type Mesh, type Object3D, Raycaster } from 'three' * those are injected per kind. */ +const previewCounts = new Map() +const previewListeners = new Set<(nodeId: string) => void>() + +export function isSlotPaintPreviewActive(nodeId: string): boolean { + return previewCounts.has(nodeId) +} + +export function subscribeSlotPaintPreviews(listener: (nodeId: string) => void): () => void { + previewListeners.add(listener) + return () => { + previewListeners.delete(listener) + } +} + +function beginSlotPaintPreview(nodeId: string): () => void { + const count = previewCounts.get(nodeId) ?? 0 + previewCounts.set(nodeId, count + 1) + if (count === 0) for (const listener of previewListeners) listener(nodeId) + return () => { + const remaining = (previewCounts.get(nodeId) ?? 1) - 1 + if (remaining > 0) previewCounts.set(nodeId, remaining) + else { + previewCounts.delete(nodeId) + for (const listener of previewListeners) listener(nodeId) + } + } +} + type SlotsNode = AnyNode & { slots?: Record } function deepEqual(a: unknown, b: unknown): boolean { @@ -271,7 +299,31 @@ export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapabil }, commit: ({ node, role, material, materialPreset }) => commitSlotPaint(node as SlotsNode, role, material, materialPreset), - applyPreview: config.applyPreview, + applyPreview: (args) => { + // Release before swapping materials, including each room/all-matching target. + const end = beginSlotPaintPreview(args.node.id) + let restore: (() => void) | null + try { + restore = config.applyPreview(args) + } catch (error) { + end() + throw error + } + if (!restore) { + end() + return null + } + let restored = false + return () => { + if (restored) return + restored = true + try { + restore() + } finally { + end() + } + } + }, getEffectiveMaterial: ({ node, role }) => { const ref = (node as SlotsNode).slots?.[role] const parsed = parseMaterialRef(ref) diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 81046a368e..e5063b27ad 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -182,6 +182,7 @@ function getLegacySlabMaterial(node: SlabNode, shading: RenderShading): Material slabMaterial.depthWrite = true slabMaterial.needsUpdate = true + material.userData.__pascalCachedMaterial = true slabMaterialCache.set(cacheKey, material) return material } diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 5a85463629..20b252c043 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -641,10 +641,10 @@ export function resolveSlotDefaultMaterial( if (parseMaterialRef(slotDefault)?.kind === 'library') { return ( createMaterialFromPresetRef(slotDefault, shading) ?? - createDefaultMaterial('#ffffff', roughness, shading) + cachedDefaultMaterial(`slot-#ffffff-${roughness}`, '#ffffff', roughness, shading) ) } - return createDefaultMaterial(slotDefault, roughness, shading) + return cachedDefaultMaterial(`slot-${slotDefault}-${roughness}`, slotDefault, roughness, shading) } export function createDefaultMaterial( diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index e490ecf697..31c5f1bfe1 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -50,7 +50,7 @@ export const CeilingSystem = () => { } // If mesh not found, keep it dirty for next frame }) - }) + }, 2) return null } diff --git a/wiki/architecture/materials-and-themes.md b/wiki/architecture/materials-and-themes.md index 0300b0d6cb..1893cace0e 100644 --- a/wiki/architecture/materials-and-themes.md +++ b/wiki/architecture/materials-and-themes.md @@ -41,7 +41,7 @@ resolveSurfaceColor(role, colorPreset, sceneThemeId?) ## The rule: untextured surfaces are theme-coloured in both modes -This is the important invariant. A surface is "textured" only if its node has an explicit `materialPreset` or `material`. +For kinds without declared slot defaults, a surface is "textured" only if its node has an explicit `materialPreset` or `material`. Kinds with slot defaults use the slot contract described below. - **`textures` off** → every surface uses `resolveSurfaceColor(role, …)`. - **`textures` on** → textured surfaces show their texture; **untextured surfaces still use `resolveSurfaceColor`** (not a hardcoded white/grey default). @@ -54,7 +54,7 @@ So picking the Mediterranean theme gives a blue roof + warm walls without touchi |---|---| | wall | `systems/wall/wall-materials.ts` (`getMaterialsForWall`), re-applied each frame by `wall-cutout.tsx` | | roof / roof-segment | `systems/roof/roof-materials.ts` (`getRoofMaterialArray`) | -| slab | `nodes/slab/geometry.ts` (`getSlabMaterial`) | +| slab | `nodes/slab/geometry.ts` (`getSlabSlotMaterial`) | | ceiling | `nodes/ceiling/renderer.tsx` | | generic registry kinds | `systems/geometry/geometry-system.tsx` → `applyDefaultSurfaceRole` (textures-off) | | door / window | `systems/{door,window}/*-system.tsx` | @@ -62,6 +62,13 @@ So picking the Mediterranean theme gives a blue roof + warm walls without touchi Each of these reads `shading`/`textures`/`colorPreset`/`sceneTheme` from `useViewer` (or receives them threaded from `GeometrySystem`) and **must include `sceneTheme` in its material cache key and its rebuild dependency array**, or theme switches won't re-colour. `GeometrySystem` marks every geometry node dirty on any of those changing. +Ceilings and slabs use declared slot defaults in colored (`textures` on) mode. +Ceiling undersides use an opaque `BackSide` material in both appearances; only +`ceiling-grid` blends. Slab top, side/underside and optional terrain skirt meshes +can batch separately. Flat slot defaults share the viewer cache by color, roughness +and shading; slab legacy cached materials carry `__pascalCachedMaterial` so geometry +rebuilds leave shared materials alive. Transparent slot overrides draw themselves. + ## Custom-mesh face materials Blocks use the reusable `MaterialRef` model through stable, user-named object slots. `BlockNode.slots` maps slot IDs to `scene:` or `library:` references, `slotNames` stores their editable labels, and each `BlockFace.materialSlot` stores one slot ID. `body` is the permanent base slot and the fallback for unbound or unresolved slots. diff --git a/wiki/architecture/node-definitions.md b/wiki/architecture/node-definitions.md index 532c93ad68..67c8ecdedb 100644 --- a/wiki/architecture/node-definitions.md +++ b/wiki/architecture/node-definitions.md @@ -184,7 +184,7 @@ If the system also handles cascades, animations, or material updates, keep `def. - **Builders must be pure.** No `useScene` import inside a `def.geometry` function. Read scene state via `ctx`. Mutating the store from a builder breaks idempotence. - **Builders emit local-space children.** The registered `` is positioned/rotated by `` via JSX (`position={liveTransform?.position ?? node.position}`). Builders return geometry as if the parent were at the origin — never bake the node's world position into vertex coords. - **One mesh registered per node ID.** The generic renderer registers a single `` per node. If a custom renderer mounts multiple meshes, register the parent group (or whichever object the system needs to address). -- **Custom systems run in addition to the generic system, not instead of it.** A kind with `def.geometry` + `def.system` will see the generic system rebuild children on dirty AND the per-kind system run its `useFrame`. Plan priorities accordingly: door-animation runs at priority 2, geometry rebuild at priority 3. +- **Custom systems run in addition to the generic system, not instead of it.** A kind with `def.geometry` + `def.system` will see the generic system rebuild children on dirty AND the per-kind system run its `useFrame`. Plan priorities accordingly: `GeometrySystem` and ceiling dirty consumption run at frame priority 2, after the node batch's priority-1 dirty snapshot. `def.system.priority` orders components, not frame callbacks. - **Dispose on rebuild.** The generic system disposes the previous children's geometry + material before swapping. Custom systems that imperatively add children must dispose what they replace, or accept the GPU-memory cost. - **`def.renderer` overrides the generic renderer.** Once you set it, you own the mount — `` is not invoked. The generic geometry system still runs for the kind if `def.geometry` is set, so a custom renderer can register an empty group and let the system fill it. diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index 43996378d9..155994669e 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -25,6 +25,18 @@ Pure logic: no rendering, no Three.js objects. They read nodes from `useScene`, Slab geometry has no dedicated system: it renders through the registry `def.geometry` (`packages/nodes/src/slab/geometry.ts`, calling the pure generators in `packages/viewer/src/systems/slab/slab-system.tsx`) with a small `def.system` for dirty tracking. +Ceiling geometry consumes dirty marks at frame priority 2, like `GeometrySystem` (slabs). +The node batch snapshots marks at priority 1 and processes membership at priority 5, +so it releases old geometry and collects replacements after rebuilds. A definition's +`system.priority` orders mounted components; it does not set `useFrame` priority. + +Items, columns, ceiling undersides and slab bodies directly under a level, plus +wall-hosted doors/windows, can join the level's `BatchedMesh` containers. Sources +stay mounted and draw-hidden. Ceiling grids and hosted child subtrees are excluded; +containers preserve source shadow flags. Selection (including external selection), +live transforms and each slot paint preview target release sources until settled. +Level mode/selected-level changes re-offer sources rejected while shadow-only. + ### Viewer Systems — `packages/viewer/src/systems/` Access Three.js objects (via `useRegistry`) and manage rendering side-effects. From 4b70199ce0e212d83a19b6e6544cc811c8c37740 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 8 Sep 2026 14:59:31 -0400 Subject: [PATCH 2/8] fix(nodes): close surface batch ownership and rebuild lifecycles --- .../components/editor/selection-manager.tsx | 53 ++- .../editor/src/lib/paint-preview-owner.ts | 66 +++ packages/nodes/src/item/system.tsx | 4 +- .../nodes/src/shared/node-batch/candidates.ts | 4 +- .../src/shared/node-batch/node-batch.test.ts | 276 +++++++++++ .../shared/node-batch/source-systems.test.ts | 47 +- packages/nodes/src/shared/node-batch/store.ts | 447 +++++++++--------- .../nodes/src/shared/node-batch/system.tsx | 69 ++- packages/nodes/src/shared/node-batch/types.ts | 15 +- packages/nodes/src/shared/slot-paint.ts | 11 +- packages/nodes/src/slab/geometry.ts | 28 ++ .../src/components/viewer/perf-monitor.tsx | 1 + packages/viewer/src/index.ts | 1 + packages/viewer/src/lib/materials.ts | 10 + packages/viewer/src/lib/perf-panel-store.ts | 11 +- 15 files changed, 780 insertions(+), 263 deletions(-) create mode 100644 packages/editor/src/lib/paint-preview-owner.ts diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 142f61c80d..26661e93b4 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -54,6 +54,11 @@ import { hasActivePaintMaterial, resolveActivePaintMaterialFromSelection, } from '../../lib/material-paint' +import { + combinePaintPreviews, + createPaintPreviewOwner, + type PaintPreviewCleanup, +} from '../../lib/paint-preview-owner' import { availablePaintScopes, commitPaintScopeFanout, @@ -116,8 +121,6 @@ type SelectableNodeType = | 'window' | 'door' -type PaintPreviewCleanup = () => void - type PaintInteraction = { key: string apply: (() => void) | null @@ -809,6 +812,7 @@ export const SelectionManager = () => { if (mode !== 'material-paint') return if (movingNode || isCurveReshape) return + const previewOwner = createPaintPreviewOwner() let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null // The last hover event, replayed when the application scope cycles so the // preview + chip update under a stationary cursor (Shift fires no pointer move). @@ -830,7 +834,7 @@ export const SelectionManager = () => { selectedMaterialTarget: useEditor.getState().selectedMaterialTarget, }) - const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => { + const resolvePaintInteraction = (event: NodeEvent): PaintInteraction | null => { const eraser = useEditor.getState().paintEraser const activePaintMaterial = resolveActivePaintMaterial() const node = event.node @@ -953,27 +957,29 @@ export const SelectionManager = () => { // paint capability builds the preview; restores combine. const restores: PaintPreviewCleanup[] = [] const sceneNodes = useScene.getState().nodes - for (const target of scopeTargets) { - const targetNode = sceneNodes[target.nodeId] - const targetRoot = getRegisteredNodeObject(target.nodeId) - const targetCap = targetNode - ? nodeRegistry.get(targetNode.type)?.capabilities?.paint - : null - if (!(targetNode && targetRoot && targetCap)) continue - const restore = targetCap.applyPreview({ - node: targetNode, - role: target.role, - material: paintSpec.material, - materialPreset: paintSpec.materialPreset, - root: targetRoot, - }) - if (restore) restores.push(restore) + try { + for (const target of scopeTargets) { + const targetNode = sceneNodes[target.nodeId] + const targetRoot = getRegisteredNodeObject(target.nodeId) + const targetCap = targetNode + ? nodeRegistry.get(targetNode.type)?.capabilities?.paint + : null + if (!(targetNode && targetRoot && targetCap)) continue + const restore = targetCap.applyPreview({ + node: targetNode, + role: target.role, + material: paintSpec.material, + materialPreset: paintSpec.materialPreset, + root: targetRoot, + }) + if (restore) restores.push(restore) + } + } catch (error) { + combinePaintPreviews(restores)() + throw error } if (restores.length === 0) return null - return () => { - for (let index = restores.length - 1; index >= 0; index -= 1) - restores[index]?.() - } + return combinePaintPreviews(restores) } : () => previewCursor('not-allowed'), } @@ -1073,6 +1079,9 @@ export const SelectionManager = () => { return null } + const getPaintInteraction = (event: NodeEvent) => + previewOwner.wrap(resolvePaintInteraction(event)) + const onEnter = (event: NodeEvent) => { // A host-driven drag (handle resize/rotate) sets `inputDragging`. // useNodeEvents now emits hover events during such a drag so surface diff --git a/packages/editor/src/lib/paint-preview-owner.ts b/packages/editor/src/lib/paint-preview-owner.ts new file mode 100644 index 0000000000..e7f7befca2 --- /dev/null +++ b/packages/editor/src/lib/paint-preview-owner.ts @@ -0,0 +1,66 @@ +export type PaintPreviewCleanup = (() => void) & { commit?: () => void } + +type Interaction = { + key: string + apply: (() => void) | null + preview: (() => PaintPreviewCleanup | null) | null +} + +export function combinePaintPreviews(previews: PaintPreviewCleanup[]): PaintPreviewCleanup { + const finish = (committed: boolean) => { + const pending = previews.splice(0).reverse() + let failure: unknown + for (const cleanup of pending) { + try { + if (committed) cleanup.commit?.() + else cleanup() + } catch (error) { + failure ??= error + } + } + if (failure) throw failure + } + return Object.assign(() => finish(false), { commit: () => finish(true) }) +} + +export function createPaintPreviewOwner() { + let active: { key: string; cleanup: PaintPreviewCleanup } | null = null + const end = (committed = false) => { + const previous = active + active = null + if (committed) previous?.cleanup.commit?.() + else previous?.cleanup() + } + return { + wrap(interaction: T | null): T | null { + if (!interaction) return null + return { + ...interaction, + preview: interaction.preview + ? () => { + end() + const cleanup = interaction.preview!() + if (!cleanup) return null + const owned = { key: interaction.key, cleanup } + active = owned + return () => { + if (active === owned) end() + } + } + : null, + apply: interaction.apply + ? () => { + if (active && active.key !== interaction.key) end() + try { + interaction.apply!() + } catch (error) { + end() + throw error + } + end(true) + } + : null, + } + }, + } +} diff --git a/packages/nodes/src/item/system.tsx b/packages/nodes/src/item/system.tsx index 37971d0638..faf63523e9 100644 --- a/packages/nodes/src/item/system.tsx +++ b/packages/nodes/src/item/system.tsx @@ -10,8 +10,8 @@ import { NodeBatchSystem } from '../shared/node-batch/system' * (wall-side z-offset, slab elevation, ceiling mounting). * - **`ItemLightSystem`** — manages light sources attached to items * (lamps, ceiling lights, etc.). - * - **`NodeBatchSystem`** — once nodes stop changing, draws items, columns - * and wall-hosted openings through per-material BatchedMeshes; lit or + * - **`NodeBatchSystem`** — once nodes stop changing, draws items, columns, + * ceilings, slabs and wall-hosted openings through per-material BatchedMeshes; lit or * edited nodes draw themselves (see ../shared/node-batch/types.ts). * Mounted from the item bundle because it must mount exactly once and * every registered kind's system mounts scene-wide. diff --git a/packages/nodes/src/shared/node-batch/candidates.ts b/packages/nodes/src/shared/node-batch/candidates.ts index 1f170da51c..fc95fe5e60 100644 --- a/packages/nodes/src/shared/node-batch/candidates.ts +++ b/packages/nodes/src/shared/node-batch/candidates.ts @@ -159,7 +159,7 @@ export function collectBatchCandidate(nodeId: string): BatchCandidate | null { rootInverse.copy(levelRoot.matrixWorld).invert() const entries: BatchEntry[] = [] - for (const mesh of meshes) { + for (const [meshIndex, mesh] of meshes.entries()) { const material = mesh.material as Material | Material[] // Array materials draw per geometry group — a shape BatchedMesh cannot // hold; transparent ones depend on per-object blend ordering (door/window @@ -174,6 +174,8 @@ export function collectBatchCandidate(nodeId: string): BatchCandidate | null { entries.push({ nodeId, levelId, + allocationKey: + node.type === 'ceiling' || node.type === 'slab' ? `${nodeId}:${meshIndex}` : undefined, mesh, geometry: mesh.geometry, material, diff --git a/packages/nodes/src/shared/node-batch/node-batch.test.ts b/packages/nodes/src/shared/node-batch/node-batch.test.ts index 565817d840..80ac1c6d5a 100644 --- a/packages/nodes/src/shared/node-batch/node-batch.test.ts +++ b/packages/nodes/src/shared/node-batch/node-batch.test.ts @@ -8,6 +8,7 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' +import * as viewerExports from '@pascal-app/viewer' import { SCENE_LAYER, useViewer } from '@pascal-app/viewer' import { BackSide, @@ -18,6 +19,11 @@ import { Mesh, MeshBasicMaterial, } from 'three' +import { + combinePaintPreviews, + createPaintPreviewOwner, +} from '../../../../editor/src/lib/paint-preview-owner' +import { commitPaintScopeFanout } from '../../../../editor/src/lib/paint-scope' import { applyShadowOnly, clearShadowOnly } from '../../../../viewer/src/lib/shadow-only' import { getCeilingMaterials } from '../../ceiling/materials' import { ceilingPaint } from '../../ceiling/paint' @@ -337,3 +343,273 @@ test.each([ useInteractive.setState({ [`${kind}Animations`]: { [`${kind}_0`]: {} } } as never) expect(collectBatchCandidate(`${kind}_0`)).toBeNull() }) + +test('paint interaction apply then drop ends every fan-out hold without restoring committed materials', () => { + const { meshes } = setup() + settle() + const targets = ['ceiling_0', 'ceiling_1', 'ceiling_2'].map((nodeId) => ({ + nodeId, + role: 'surface', + })) + const owner = createPaintPreviewOwner() + let interaction = owner.wrap({ + key: 'all-matching', + preview: () => + combinePaintPreviews( + targets.map( + ({ nodeId, role }) => + ceilingPaint.applyPreview({ + node: useScene.getState().nodes[nodeId]!, + root: sceneRegistry.nodes.get(nodeId)!, + role, + material: { properties: { color: '#ff0000' } } as never, + materialPreset: undefined, + })!, + ), + ), + apply: () => + commitPaintScopeFanout( + targets as never, + { properties: { color: '#ff0000' } } as never, + undefined, + ), + }) + interaction!.preview!() + const previewMaterials = meshes.slice(0, 3).map((mesh) => mesh.material) + settle() + expect(targets.every(({ nodeId }) => isSlotPaintPreviewActive(nodeId))).toBe(true) + interaction!.apply!() + interaction = null + expect(targets.every(({ nodeId }) => !isSlotPaintPreviewActive(nodeId))).toBe(true) + expect(meshes.slice(0, 3).map((mesh) => mesh.material)).toEqual(previewMaterials) + captureChangedNodes() + useScene.getState().dirtyNodes.clear() + settle() + expect(meshes.slice(0, 3).every((mesh) => !mesh.layers.isEnabled(SCENE_LAYER))).toBe(true) +}) + +test('same-size surface rebuild replaces its reserved slot without growing used or rebuilding', () => { + const { root, meshes } = setup('slab') + const store = new NodeBatchStore(() => root) + stores.push(store) + store.join( + meshes.map((_, i) => candidate(`slab_${i}`)), + 1, + ) + const batch = batches(root)[0]! + const records = ( + store as unknown as { batches: Map } + ).batches + const used = { ...records.values().next().value!.used } + const range = { ...batch.getGeometryRangeAt(0)! } + const replace = spyOn(batch, 'setGeometryAt') + const bytes = store.stats().geometryBytesCopied + store.release('slab_0') + meshes[0]!.geometry = new BoxGeometry(2, 1, 1) + store.join([candidate('slab_0')], 1) + expect(batches(root)[0]).toBe(batch) + expect(replace).toHaveBeenCalledTimes(1) + expect(records.values().next().value!.used).toEqual(used) + expect(batch.getGeometryRangeAt(0)).toEqual(range) + expect(store.stats().overflowRebuilds).toBe(0) + expect(store.stats().geometryReplacements).toBe(1) + expect(store.stats().geometryBytesCopied).toBeGreaterThan(bytes) + replace.mockRestore() +}) + +test('surface slot overflow rebuilds once from live reservations, reclaiming released allocations', () => { + const { root, meshes } = setup('slab', 12) + const store = new NodeBatchStore(() => root) + stores.push(store) + store.join( + meshes.map((_, i) => candidate(`slab_${i}`)), + 1, + ) + const old = batches(root)[0]! + for (let i = 0; i < 11; i++) store.release(`slab_${i}`) + meshes[0]!.geometry = new BoxGeometry(2, 1, 1, 12, 12, 12) + store.join([candidate('slab_0')], 1) + const batch = batches(root)[0]! + expect(batch).not.toBe(old) + expect(store.stats().overflowRebuilds).toBe(1) + const liveVertices = + Math.max(36, Math.ceil(meshes[0]!.geometry.attributes.position!.count * 1.25)) + 36 + expect(batch.geometry.attributes.position!.count).toBe(liveVertices * 2) + expect(batch.instanceCount).toBe(2) +}) + +test('N releases in a frame delete once per instance and publish stats once', () => { + const { root } = setup('slab', 20) + settle() + const batch = batches(root)[0]! + const deletion = spyOn(batch, 'deleteInstance') + const publish = spyOn(viewerExports, 'publishPerfBatchStats') + const flush = spyOn(NodeBatchStore.prototype, 'flushReleases') + useLiveTransforms.getState().set('slab_0', { position: [1, 0, 0], rotation: 0 }) + for (let i = 0; i < 15; i++) useScene.getState().dirtyNodes.add(`slab_${i}` as never) + captureChangedNodes() + useScene.getState().dirtyNodes.clear() + frame() + expect(flush).toHaveBeenCalledTimes(1) + expect(deletion).toHaveBeenCalledTimes(15) + expect(batch.instanceCount).toBe(5) + expect(publish).toHaveBeenCalledTimes(1) + expect(publish.mock.calls[0]![0].instances).toBe(5) + deletion.mockRestore() + publish.mockRestore() + flush.mockRestore() +}) + +test('empty container survives until quiet, is reused by a rejoin, and expires if unused', () => { + const { root } = setup('slab', 1) + const store = new NodeBatchStore(() => root) + stores.push(store) + store.join([candidate('slab_0')], 1) + const batch = batches(root)[0]! + store.release('slab_0') + store.flushReleases() + now = 179 + store.pruneEmpty() + expect(batches(root)[0]).toBe(batch) + now = 181 + store.join([candidate('slab_0')], 3) + store.pruneEmpty() + expect(batches(root)[0]).toBe(batch) + expect(store.stats().overflowRebuilds).toBe(0) + store.release('slab_0') + store.flushReleases() + now += 181 + store.pruneEmpty() + expect(batches(root)).toHaveLength(0) +}) + +test('level surfaces wait through wall override, wall queue drain and quiet, then join in one wave', () => { + const { root, meshes } = setup('slab', 6) + const level2 = new Group() + sceneRegistry.nodes.set('level_second', level2) + sceneRegistry.byType.level.add('level_second') + const nodes = { + ...useScene.getState().nodes, + wall_drag: { id: 'wall_drag', type: 'wall', parentId: 'level_test', children: [] }, + level_second: { id: 'level_second', type: 'level', children: [] }, + } as Record + for (let i = 3; i < 6; i++) { + level2.add(meshes[i]!) + nodes[`slab_${i}`] = { ...nodes[`slab_${i}`], parentId: 'level_second' } + } + useScene.setState({ nodes } as never) + settle() + const join = spyOn(NodeBatchStore.prototype, 'join') + let pending = 0 + const queue = spyOn(viewerExports, 'getPendingWallRebuildCount').mockImplementation(() => pending) + useLiveNodeOverrides.getState().set('wall_drag', { visible: true } as Partial) + for (const id of ['slab_0', 'slab_1', 'slab_3']) useScene.getState().dirtyNodes.add(id as never) + captureChangedNodes() + useScene.getState().dirtyNodes.clear() + settle() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + expect(meshes[1]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + expect(meshes[2]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + expect(meshes[3]!.layers.isEnabled(SCENE_LAYER)).toBe(false) + expect(batches(root)[0]!.instanceCount).toBe(1) + pending = 2 + useLiveNodeOverrides.getState().clearAll() + settle() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + pending = 0 + frame() + now += 179 + frame() + expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) + join.mockClear() + now += 2 + frame() + expect(meshes.slice(0, 3).every((mesh) => !mesh.layers.isEnabled(SCENE_LAYER))).toBe(true) + expect(join).toHaveBeenCalledTimes(1) + expect(join.mock.calls[0]![0].map(({ nodeId }) => nodeId)).toEqual(['slab_0', 'slab_1']) + join.mockRestore() + queue.mockRestore() +}) + +test('superseded, cancelled and failed paint interactions end holds once without ending a newer owner', () => { + setup() + let restored = 0 + const paint = createSlotPaintCapability({ + resolveRole: () => 'surface', + applyPreview: () => () => { + restored++ + }, + }) + const owner = createPaintPreviewOwner() + const interaction = (key: string, apply = () => {}) => + owner.wrap({ + key, + apply, + preview: () => + combinePaintPreviews([ + paint.applyPreview({ + node: useScene.getState().nodes.ceiling_0!, + root: sceneRegistry.nodes.get('ceiling_0')!, + role: 'surface', + material: undefined, + materialPreset: undefined, + })!, + ]), + })! + const first = interaction('first') + const cancelFirst = first.preview!()! + const second = interaction('second') + const cancelSecond = second.preview!()! + expect(restored).toBe(1) + cancelFirst() + expect(isSlotPaintPreviewActive('ceiling_0')).toBe(true) + second.apply!() + cancelSecond() + expect(restored).toBe(1) + expect(isSlotPaintPreviewActive('ceiling_0')).toBe(false) + const cancel = interaction('cancel').preview!()! + cancel() + cancel() + expect(restored).toBe(2) + expect(isSlotPaintPreviewActive('ceiling_0')).toBe(false) + const failed = interaction('failed', () => { + throw new Error('commit failed') + }) + failed.preview!() + expect(() => failed.apply!()).toThrow('commit failed') + expect(restored).toBe(3) + expect(isSlotPaintPreviewActive('ceiling_0')).toBe(false) +}) + +test('deleting the last members schedules empty-container expiry without a rejoin candidate', () => { + const { root } = setup('slab', 3) + settle() + const batch = batches(root)[0]! + if (wakeRef.current) clearTimeout(wakeRef.current) + wakeRef.current = null + for (let i = 0; i < 3; i++) { + sceneRegistry.nodes.delete(`slab_${i}`) + sceneRegistry.byType.slab.delete(`slab_${i}`) + } + frame() + expect(batch.instanceCount).toBe(0) + expect(batches(root)).toHaveLength(1) + expect(wakeRef.current).not.toBeNull() + now += 181 + frame() + expect(batches(root)).toHaveLength(0) +}) + +test('level remount releases orphaned draws and restores sources before collecting replacement batches', () => { + const { root, meshes } = setup('slab') + settle() + const replacement = new Group() + replacement.add(...meshes) + sceneRegistry.nodes.set('level_test', replacement) + frame() + expect(batches(root)).toHaveLength(0) + expect(meshes.every((mesh) => mesh.layers.isEnabled(SCENE_LAYER))).toBe(true) + settle() + expect(batches(replacement)).toHaveLength(1) + expect(meshes.every((mesh) => !mesh.layers.isEnabled(SCENE_LAYER))).toBe(true) +}) diff --git a/packages/nodes/src/shared/node-batch/source-systems.test.ts b/packages/nodes/src/shared/node-batch/source-systems.test.ts index 5e29fe0b13..66fc9d0156 100644 --- a/packages/nodes/src/shared/node-batch/source-systems.test.ts +++ b/packages/nodes/src/shared/node-batch/source-systems.test.ts @@ -28,7 +28,7 @@ function runSourceTest(body: string) { test('real slab top/side/skirt collection, shared defaults, transparent overrides and cache ownership', () => { runSourceTest(` const sourceMaterials = await import('./packages/viewer/src/lib/materials.ts') - mock.module('@pascal-app/viewer', () => ({ ...viewer, resolveSlotDefaultMaterial: sourceMaterials.resolveSlotDefaultMaterial })) + mock.module('@pascal-app/viewer', () => ({ ...viewer, registerMaterialCacheCleanup: sourceMaterials.registerMaterialCacheCleanup, resolveSlotDefaultMaterial: sourceMaterials.resolveSlotDefaultMaterial })) const { buildSlabGeometry } = await import('./packages/nodes/src/slab/geometry.ts') const { collectBatchCandidate } = await import('./packages/nodes/src/shared/node-batch/candidates.ts') const { disposeObject3DResources } = await import('./packages/viewer/src/lib/dispose-object3d.ts') @@ -65,7 +65,10 @@ test('real slab top/side/skirt collection, shared defaults, transparent override side.addEventListener('dispose', () => disposed++) disposeObject3DResources(first) assert.equal(disposed, 0) - const legacy = { ...slab, material: { properties: { color: '#123456' } } } + const preset = { ...core.MATERIAL_CATALOG[0], id: 'surface-cache-test', preset: { ...core.MATERIAL_CATALOG[0].preset, maps: {} } } + core.registerLibraryMaterials([preset]) + const legacy = { ...slab, materialPreset: 'library:surface-cache-test' } + assert(core.getMaterialPresetByRef(legacy.materialPreset)) const legacyFirst = buildSlabGeometry(legacy, ctx, 'solid') const legacySecond = buildSlabGeometry(legacy, ctx, 'solid') const top = legacyFirst.children[0].material @@ -74,26 +77,38 @@ test('real slab top/side/skirt collection, shared defaults, transparent override disposeObject3DResources(legacyFirst) assert.equal(disposed, 0) assert.equal(top.transparent, false) + const { BatchedMesh } = await import('three') + const batch = new BatchedMesh(4, 1024, 1024, top) + root.add(legacySecond, batch) + let reboundBeforeDispose = false + top.addEventListener('dispose', () => { reboundBeforeDispose = legacySecond.children[0].material !== top }) + sourceMaterials.clearMaterialCache() + assert.equal(reboundBeforeDispose, true) + assert.notEqual(legacySecond.children[0].material, top) + assert.equal(batch.material, legacySecond.children[0].material) + assert.equal(buildSlabGeometry(legacy, ctx, 'solid').children[0].material, legacySecond.children[0].material) + assert.equal(disposed, 2) + root.remove(legacySecond, batch) + sourceMaterials.clearMaterialCache() + assert.notEqual(buildSlabGeometry(legacy, ctx, 'solid').children[0].material, legacySecond.children[0].material) assert.notEqual(sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'solid', 0.8), sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'rendered', 0.8)) assert.notEqual(sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'rendered', 0.8), sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'rendered', 0.4)) `) }) test('priority-1 dirty snapshot sees the priority-2 ceiling rebuild and batches replacement geometry at 5', () => { - runSourceTest(String.raw` + runSourceTest(` const scene = core.useScene const selectorHook = Object.assign((selector) => selector(scene.getState()), scene) mock.module('@pascal-app/core', () => ({ ...core, useScene: selectorHook })) const fiber = await import('@react-three/fiber') const callbacks = [] - mock.module('@react-three/fiber', () => ({ ...fiber, useFrame: (callback, priority = 0) => callbacks.push({ callback, priority }) })) + mock.module('@react-three/fiber', () => ({ ...fiber, useThree: (selector) => selector({ invalidate: () => {} }), useFrame: (callback, priority = 0) => callbacks.push({ callback, priority }) })) + const react = await import('react') + const refs = [] + mock.module('react', () => ({ ...react, useEffect: () => {}, useRef: (value) => { const ref = { current: value }; refs.push(ref); return ref } })) const { CeilingSystem, generateCeilingGeometry } = await import('./packages/viewer/src/systems/ceiling/ceiling-system.tsx') - const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import('./packages/nodes/src/shared/node-batch/system.tsx') - const batchSource = await Bun.file('./packages/nodes/src/shared/node-batch/system.tsx').text() - assert.match(batchSource, /useFrame\(captureChangedNodes, 1\)/) - assert.match(batchSource, /useFrame\(\(\) => runBatchFrame\(invalidate, wakeRef\), 5\)/) - const geometrySource = await Bun.file('./packages/viewer/src/systems/geometry/geometry-system.tsx').text() - assert.match(geometrySource, /}, 2\)/) + const { NodeBatchSystem, runBatchFrame, resetNodeBatchState } = await import('./packages/nodes/src/shared/node-batch/system.tsx') let now = 0 performance.now = () => now const root = new Group() @@ -123,8 +138,15 @@ test('priority-1 dirty snapshot sees the priority-2 ceiling rebuild and batches CeilingSystem() assert.equal(callbacks.length, 1) assert.equal(callbacks[0].priority, 2) - const pipeline = [{ priority: 1, callback: captureChangedNodes }, callbacks[0], { priority: 5, callback: frame }].sort((a,b) => a.priority - b.priority) - assert.deepEqual(pipeline.map((pass) => pass.priority), [1, 2, 5]) + NodeBatchSystem().type() + assert.deepEqual(callbacks.map((pass) => pass.priority), [2, 1, 5]) + const viewerStore = viewer.useViewer + mock.module('./packages/viewer/src/store/use-viewer.ts', () => ({ default: Object.assign((selector) => selector(viewerStore.getState()), viewerStore) })) + const { GeometrySystem } = await import('./packages/viewer/src/systems/geometry/geometry-system.tsx') + GeometrySystem() + assert.equal(callbacks[3].priority, 2) + const pipeline = callbacks.sort((a,b) => a.priority - b.priority) + assert.deepEqual(pipeline.map((pass) => pass.priority), [1, 2, 2, 5]) for (const pass of pipeline) pass.callback() assert.equal(scene.getState().dirtyNodes.has('ceiling_0'), false) assert.notEqual(meshes[0].geometry, oldGeometry) @@ -135,5 +157,6 @@ test('priority-1 dirty snapshot sees the priority-2 ceiling rebuild and batches assert(packed.some((batch) => Array.from(batch.geometry.attributes.position.array).includes(8))) resetNodeBatchState() if (wakeRef.current) clearTimeout(wakeRef.current) + for (const ref of refs) if (ref.current) clearTimeout(ref.current) `) }) diff --git a/packages/nodes/src/shared/node-batch/store.ts b/packages/nodes/src/shared/node-batch/store.ts index 6d49f36871..62b26f8c50 100644 --- a/packages/nodes/src/shared/node-batch/store.ts +++ b/packages/nodes/src/shared/node-batch/store.ts @@ -1,25 +1,15 @@ -import { BatchedMesh, type BufferGeometry, type Material, type Matrix4, type Object3D } from 'three' -import type { - BatchCandidate, - BatchEntry, - GetLevelRoot, - NodeBatchStats, - NodeBatchStoreApi, +import { BatchedMesh, type BufferGeometry, type Material, type Object3D } from 'three' +import { + type BatchCandidate, + type BatchEntry, + type GetLevelRoot, + NODE_BATCH_SETTLE_MS, + type NodeBatchStats, + type NodeBatchStoreApi, } from './types' -/** - * BatchedMesh container for node batching — see types.ts for the - * architecture invariants. One BatchedMesh per level, material, attribute - * layout and shadow flags, parented under the level root; membership changes - * are instance adds/deletes, and only a capacity overflow rebuilds a batch. - */ +function skipRaycast() {} -/** Batch meshes are draw-only; sources keep every raycast (wall-batch rule). */ -function skipRaycast() { - // intentionally empty -} - -/** Interleaved attributes carry their version on the shared buffer. */ function positionVersion(geometry: BufferGeometry): number { const attribute = geometry.attributes.position as | { version?: number; data?: { version?: number } } @@ -29,71 +19,77 @@ function positionVersion(geometry: BufferGeometry): number { type PackedGeometry = { id: number - positionVersion: number + uuid: string + version: number vertices: number indices: number + reservedVertices: number + reservedIndices: number } - -type InstanceRecord = { - nodeId: string - geometry: BufferGeometry - matrix: Matrix4 - instanceId: number -} - +type InstanceRecord = { entry: BatchEntry; instanceId: number } type BatchRecord = { levelId: string - material: Material batched: BatchedMesh - /** - * geometry.uuid → the packed copy's id plus a content stamp. The mapping - * survives release/rejoin cycles (hover churn must not grow the buffer), - * and the stamp — position version + counts — detects a geometry rebuilt - * in place under the same uuid, which then re-packs instead of instancing - * stale vertices. - */ geometryIds: Map instances: InstanceRecord[] capacity: { instances: number; vertices: number; indices: number } used: { vertices: number; indices: number } + emptySince: number | null } -/** - * BatchedMesh requires every geometry it holds to share one attribute layout - * (GLB assets differ — some carry `uv1`, some don't) and one index-ness, so - * both are part of the batch identity alongside level and material. - */ function attributeSignature(geometry: BufferGeometry): string { - return `${Object.keys(geometry.attributes).sort().join(',')}|${geometry.index ? 'i' : 'n'}` + return `${Object.entries(geometry.attributes) + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([name, attribute]) => + `${name}:${attribute.itemSize}:${attribute.normalized}:${attribute.array.constructor.name}`, + ) + .join(',')}|${geometry.index ? 'i' : 'n'}` } - const batchKey = (entry: BatchEntry) => `${entry.levelId}|${entry.material.uuid}|${attributeSignature(entry.geometry)}|${entry.castShadow}|${entry.receiveShadow}` - -function vertexCount(geometry: BufferGeometry): number { - return geometry.attributes.position?.count ?? 0 +const geometryKey = (entry: BatchEntry) => entry.allocationKey ?? entry.geometry.uuid +const vertexCount = (geometry: BufferGeometry) => geometry.attributes.position?.count ?? 0 +const indexCount = (geometry: BufferGeometry) => geometry.index?.count ?? vertexCount(geometry) +function reservation(entry: BatchEntry) { + const reserve = (count: number) => + entry.allocationKey ? Math.max(count + 12, Math.ceil(count * 1.25)) : count + return { + vertices: reserve(vertexCount(entry.geometry)), + indices: reserve(indexCount(entry.geometry)), + } } - -function indexCount(geometry: BufferGeometry): number { - // Non-indexed geometry still consumes index capacity in BatchedMesh (it - // indexes the vertices 1:1 internally). - return geometry.index?.count ?? vertexCount(geometry) +function matches(packed: PackedGeometry, geometry: BufferGeometry) { + return ( + packed.uuid === geometry.uuid && + packed.version === positionVersion(geometry) && + packed.vertices === vertexCount(geometry) && + packed.indices === indexCount(geometry) + ) } export class NodeBatchStore implements NodeBatchStoreApi { - private readonly getLevelRoot: GetLevelRoot private readonly batches = new Map() private readonly keysByNode = new Map>() - - constructor(getLevelRoot: GetLevelRoot) { - this.getLevelRoot = getLevelRoot + private readonly instancesByNode = new Map< + string, + Array<{ record: BatchRecord; instanceId: number }> + >() + private readonly pending = new Map>() + private instanceCount = 0 + private readonly counters = { + releases: 0, + joins: 0, + geometryReplacements: 0, + overflowRebuilds: 0, + geometryBytesCopied: 0, } + constructor(private readonly getLevelRoot: GetLevelRoot) {} + join(candidates: BatchCandidate[], minEntriesForNewBatch: number): BatchEntry[] { + this.flushReleases() const joined: BatchEntry[] = [] - - // Group the whole wave by batch identity so a new batch's viability and - // capacity are judged across every candidate at once. const byBatch = new Map() for (const candidate of candidates) { for (const entry of candidate.entries) { @@ -104,26 +100,12 @@ export class NodeBatchStore implements NodeBatchStoreApi { else byBatch.set(key, [entry]) } } - for (const [key, entries] of byBatch) { let record = this.batches.get(key) if (!record && entries.length < minEntriesForNewBatch) continue const root = this.getLevelRoot(entries[0]!.levelId) if (!root) continue - - const newGeometries = new Map() - for (const entry of entries) { - if (!record || !this.packedMatches(record, entry.geometry)) { - newGeometries.set(entry.geometry.uuid, entry.geometry) - } - } - let addedVertices = 0 - let addedIndices = 0 - for (const geometry of newGeometries.values()) { - addedVertices += vertexCount(geometry) - addedIndices += indexCount(geometry) - } - + const needed = this.requiredSpace(entries, record) if (!record) { record = this.createBatch( entries[0]!.levelId, @@ -131,125 +113,221 @@ export class NodeBatchStore implements NodeBatchStoreApi { entries[0]!, root, entries.length, - addedVertices, - addedIndices, + needed.vertices, + needed.indices, ) this.batches.set(key, record) } else if ( + needed.overflow || record.instances.length + entries.length > record.capacity.instances || - record.used.vertices + addedVertices > record.capacity.vertices || - record.used.indices + addedIndices > record.capacity.indices + record.used.vertices + needed.vertices > record.capacity.vertices || + record.used.indices + needed.indices > record.capacity.indices ) { - record = this.rebuildBatch(key, record, root, entries.length, addedVertices, addedIndices) + // Only resident geometry and this join wave survive compaction. Released + // allocations must never contribute to the replacement container's size. + const live = [...record.instances.map(({ entry }) => entry), ...entries] + const size = this.requiredSpace(live) + const survivors = record.instances + const oldRecord = record + this.disposeBatch(key, record) + record = this.createBatch( + entries[0]!.levelId, + entries[0]!.material, + entries[0]!, + root, + live.length, + size.vertices, + size.indices, + ) + this.batches.set(key, record) + for (const { entry } of survivors) { + const other = + this.instancesByNode.get(entry.nodeId)?.filter(({ record }) => record !== oldRecord) ?? + [] + this.instancesByNode.set(entry.nodeId, other) + } + for (const { entry } of survivors) this.addEntry(key, record, entry) + this.counters.overflowRebuilds++ } - for (const entry of entries) { - let geometryId = this.packedMatches(record, entry.geometry) - ? record.geometryIds.get(entry.geometry.uuid)!.id - : undefined - if (geometryId === undefined) { - // Signature grouping should make this infallible; a throw here mid- - // frame would still leave half-joined bookkeeping, so a geometry - // three rejects is skipped instead — that mesh just keeps drawing - // itself. - try { - geometryId = record.batched.addGeometry(entry.geometry) - } catch { - continue - } - record.geometryIds.set(entry.geometry.uuid, this.stamp(geometryId, entry.geometry)) - record.used.vertices += vertexCount(entry.geometry) - record.used.indices += indexCount(entry.geometry) - } - const instanceId = record.batched.addInstance(geometryId) - record.batched.setMatrixAt(instanceId, entry.matrixInLevel) + this.addEntry(key, record, entry) + this.instanceCount++ + this.counters.joins++ joined.push(entry) - record.instances.push({ - nodeId: entry.nodeId, - geometry: entry.geometry, - matrix: entry.matrixInLevel, - instanceId, - }) - let keys = this.keysByNode.get(entry.nodeId) - if (!keys) { - keys = new Set() - this.keysByNode.set(entry.nodeId, keys) - } - keys.add(key) } + record.emptySince = null } return joined } - /** - * Drops batches whose mesh is no longer a child of the live level root — a - * React remount of the level subtree (thumbnail capture's level shuffling, - * tool-state changes) replaces the registry groups and silently orphans the - * imperatively-parented batch meshes, while fresh source clones mount with - * no layer hold. Returns the affected item ids so the caller can release - * and re-stale them against the new scene graph. - */ - pruneDetached(): Set { - const orphaned = new Set() - for (const [key, record] of [...this.batches]) { - const root = this.getLevelRoot(record.levelId) - if (root && record.batched.parent === root) continue - for (const instance of record.instances) orphaned.add(instance.nodeId) - record.batched.removeFromParent() - record.batched.dispose() - this.batches.delete(key) + private requiredSpace(entries: BatchEntry[], record?: BatchRecord) { + let vertices = 0 + let indices = 0 + let overflow = false + const seen = new Set() + for (const entry of entries) { + const key = geometryKey(entry) + if (seen.has(key)) continue + seen.add(key) + const packed = record?.geometryIds.get(key) + if (packed) { + if ( + vertexCount(entry.geometry) > packed.reservedVertices || + indexCount(entry.geometry) > packed.reservedIndices + ) + overflow = true + } else { + const size = reservation(entry) + vertices += size.vertices + indices += size.indices + } } - return orphaned + return { vertices, indices, overflow } } - release(nodeId: string): boolean { - const keys = this.keysByNode.get(nodeId) - if (!keys) return false + private addEntry(key: string, record: BatchRecord, entry: BatchEntry) { + const allocation = geometryKey(entry) + let packed = record.geometryIds.get(allocation) + if (!packed || !matches(packed, entry.geometry)) { + const size = packed + ? { vertices: packed.reservedVertices, indices: packed.reservedIndices } + : reservation(entry) + let id: number + if (packed) { + id = packed.id + record.batched.setGeometryAt(id, entry.geometry) + this.counters.geometryReplacements++ + } else { + id = record.batched.addGeometry(entry.geometry, size.vertices, size.indices) + record.used.vertices += size.vertices + record.used.indices += size.indices + } + for (const attribute of Object.values(entry.geometry.attributes)) { + this.counters.geometryBytesCopied += + size.vertices * attribute.itemSize * attribute.array.BYTES_PER_ELEMENT + } + if (entry.geometry.index) + this.counters.geometryBytesCopied += + size.indices * record.batched.geometry.index!.array.BYTES_PER_ELEMENT + packed = { + id, + uuid: entry.geometry.uuid, + version: positionVersion(entry.geometry), + vertices: vertexCount(entry.geometry), + indices: indexCount(entry.geometry), + reservedVertices: size.vertices, + reservedIndices: size.indices, + } + record.geometryIds.set(allocation, packed) + } + const instanceId = record.batched.addInstance(packed.id) + record.batched.setMatrixAt(instanceId, entry.matrixInLevel) + record.instances.push({ entry, instanceId }) + let keys = this.keysByNode.get(entry.nodeId) + if (!keys) { + keys = new Set() + this.keysByNode.set(entry.nodeId, keys) + } + keys.add(key) + let instances = this.instancesByNode.get(entry.nodeId) + if (!instances) { + instances = [] + this.instancesByNode.set(entry.nodeId, instances) + } + instances.push({ record, instanceId }) + } - for (const key of keys) { - const record = this.batches.get(key) - if (!record) continue - const remaining: InstanceRecord[] = [] - for (const instance of record.instances) { - if (instance.nodeId === nodeId) record.batched.deleteInstance(instance.instanceId) - else remaining.push(instance) + release(nodeId: string): boolean { + const instances = this.instancesByNode.get(nodeId) + if (!instances) return false + for (const { record, instanceId } of instances) { + // Interactions reveal sources synchronously; hide their packed draws now, + // then compact bookkeeping once for all releases in the frame. + record.batched.setVisibleAt(instanceId, false) + let doomed = this.pending.get(record) + if (!doomed) { + doomed = new Set() + this.pending.set(record, doomed) } - record.instances = remaining - if (remaining.length === 0) this.disposeBatch(key, record) + doomed.add(instanceId) + this.instanceCount-- } + this.instancesByNode.delete(nodeId) this.keysByNode.delete(nodeId) + this.counters.releases++ return true } + flushReleases(now = performance.now()): void { + for (const [record, doomed] of this.pending) { + for (const id of doomed) record.batched.deleteInstance(id) + record.instances = record.instances.filter(({ instanceId }) => !doomed.has(instanceId)) + if (record.instances.length === 0) record.emptySince = now + } + this.pending.clear() + } + + pruneEmpty( + now = performance.now(), + retainedLevels: ReadonlySet = new Set(), + earliestDisposalAt = 0, + ): boolean { + let pending = false + for (const [key, record] of this.batches) { + if ( + record.emptySince !== null && + now - record.emptySince >= NODE_BATCH_SETTLE_MS && + now >= earliestDisposalAt && + !retainedLevels.has(record.levelId) + ) + this.disposeBatch(key, record) + else if (record.emptySince !== null) pending = true + } + return pending + } + + pruneDetached(): Set { + const orphaned = new Set() + const detached: Array<[string, BatchRecord]> = [] + for (const [key, record] of this.batches) { + const root = this.getLevelRoot(record.levelId) + if (root && record.batched.parent === root) continue + for (const { entry } of record.instances) orphaned.add(entry.nodeId) + detached.push([key, record]) + } + for (const id of orphaned) this.release(id) + for (const [key, record] of detached) this.disposeBatch(key, record) + return orphaned + } + has(nodeId: string): boolean { return this.keysByNode.has(nodeId) } - nodeIds(): ReadonlySet { return new Set(this.keysByNode.keys()) } - disposeLevel(levelId: string): void { - for (const [key, record] of [...this.batches]) { + for (const [key, record] of this.batches) { if (record.levelId !== levelId) continue - for (const instance of record.instances) { - const keys = this.keysByNode.get(instance.nodeId) - keys?.delete(key) - if (keys && keys.size === 0) this.keysByNode.delete(instance.nodeId) - } + for (const { entry } of record.instances) this.release(entry.nodeId) + this.flushReleases() this.disposeBatch(key, record) } } - disposeAll(): void { - for (const [key, record] of [...this.batches]) this.disposeBatch(key, record) + for (const [key, record] of this.batches) this.disposeBatch(key, record) this.keysByNode.clear() + this.instancesByNode.clear() + this.pending.clear() + this.instanceCount = 0 } - stats(): NodeBatchStats { - let instances = 0 - for (const record of this.batches.values()) instances += record.instances.length - return { batches: this.batches.size, instances, nodes: this.keysByNode.size } + return { + batches: this.batches.size, + instances: this.instanceCount, + nodes: this.keysByNode.size, + ...this.counters, + } } private createBatch( @@ -289,10 +367,10 @@ export class NodeBatchStore implements NodeBatchStoreApi { root.add(batched) return { levelId, - material, batched, geometryIds: new Map(), instances: [], + emptySince: null, capacity: { instances: Math.max(8, instanceCount * 2), vertices: Math.max(1024, vertices * 2), @@ -302,66 +380,9 @@ export class NodeBatchStore implements NodeBatchStoreApi { } } - private rebuildBatch( - key: string, - old: BatchRecord, - root: Object3D, - extraInstances: number, - extraVertices: number, - extraIndices: number, - ): BatchRecord { - const survivors = old.instances - old.batched.removeFromParent() - old.batched.dispose() - - const next = this.createBatch( - old.levelId, - old.material, - old.batched, - root, - survivors.length + extraInstances, - old.used.vertices + extraVertices, - old.used.indices + extraIndices, - ) - for (const instance of survivors) { - let geometryId = next.geometryIds.get(instance.geometry.uuid)?.id - if (geometryId === undefined) { - geometryId = next.batched.addGeometry(instance.geometry) - next.geometryIds.set(instance.geometry.uuid, this.stamp(geometryId, instance.geometry)) - next.used.vertices += vertexCount(instance.geometry) - next.used.indices += indexCount(instance.geometry) - } - instance.instanceId = next.batched.addInstance(geometryId) - next.batched.setMatrixAt(instance.instanceId, instance.matrix) - } - next.instances = survivors - this.batches.set(key, next) - return next - } - - private packedMatches(record: BatchRecord | undefined, geometry: BufferGeometry): boolean { - const packed = record?.geometryIds.get(geometry.uuid) - if (!packed) return false - return ( - packed.positionVersion === positionVersion(geometry) && - packed.vertices === vertexCount(geometry) && - packed.indices === indexCount(geometry) - ) - } - - private stamp(id: number, geometry: BufferGeometry): PackedGeometry { - return { - id, - positionVersion: positionVersion(geometry), - vertices: vertexCount(geometry), - indices: indexCount(geometry), - } - } - private disposeBatch(key: string, record: BatchRecord): void { + this.pending.delete(record) record.batched.removeFromParent() - // Frees the batch's internal merged buffers only — source geometries and - // the shared material belong to the live item meshes. record.batched.dispose() this.batches.delete(key) } diff --git a/packages/nodes/src/shared/node-batch/system.tsx b/packages/nodes/src/shared/node-batch/system.tsx index 5a25de6db3..edcab8f1f3 100644 --- a/packages/nodes/src/shared/node-batch/system.tsx +++ b/packages/nodes/src/shared/node-batch/system.tsx @@ -9,7 +9,12 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { isIsolationActive, publishPerfBatchStats, useViewer } from '@pascal-app/viewer' +import { + getPendingWallRebuildCount, + isIsolationActive, + publishPerfBatchStats, + useViewer, +} from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import type { Object3D } from 'three' @@ -47,6 +52,7 @@ const store = new NodeBatchStore( * marking them. */ const changedNodes = new Set() +const surfaceLevelReadyAt = new Map() const staleNodes = new Set() /** * Members whose wave joined only part of their meshes (the rest fell under @@ -114,6 +120,7 @@ function appearanceChanged(): boolean { export function resetNodeBatchState() { releaseAll() changedNodes.clear() + surfaceLevelReadyAt.clear() staleNodes.clear() partialNodes.clear() leftoverNodes.clear() @@ -138,16 +145,19 @@ function publishBatchStats() { items: stats.nodes, instances: stats.instances, containers: stats.batches, + releases: stats.releases, + joins: stats.joins, + geometryReplacements: stats.geometryReplacements, + overflowRebuilds: stats.overflowRebuilds, + geometryBytesCopied: stats.geometryBytesCopied, }) } function releaseNode(nodeId: string) { partialNodes.delete(nodeId) leftoverNodes.delete(nodeId) - if (store.release(nodeId)) { - revealBatchedNode(nodeId) - publishBatchStats() - } + store.release(nodeId) + revealBatchedNode(nodeId) } function releaseAll() { @@ -205,8 +215,45 @@ export function captureChangedNodes() { export function runBatchFrame( invalidate: () => void, wakeRef: { current: ReturnType | null }, +) { + try { + processBatchFrame(invalidate, wakeRef) + } finally { + store.flushReleases() + const emptyPending = store.pruneEmpty( + performance.now(), + new Set(surfaceLevelReadyAt.keys()), + staleNodes.size > 0 ? lastNodeChangeAtMs + NODE_BATCH_SETTLE_MS : 0, + ) + if (emptyPending && !wakeRef.current) { + wakeRef.current = setTimeout(() => { + wakeRef.current = null + invalidate() + }, NODE_BATCH_SETTLE_MS + 20) + } + publishBatchStats() + } +} + +function processBatchFrame( + invalidate: () => void, + wakeRef: { current: ReturnType | null }, ) { const nodeIds = getBatchableNodeIds() + const frameNow = performance.now() + const sceneNodes = useScene.getState().nodes + const draggingLevels = new Set() + for (const id of useLiveNodeOverrides.getState().overrides.keys()) { + const node = sceneNodes[id as AnyNodeId] + if (node?.type === 'wall' && node.parentId) draggingLevels.add(node.parentId) + } + for (const level of draggingLevels) surfaceLevelReadyAt.set(level, Infinity) + const wallsPending = getPendingWallRebuildCount() > 0 + for (const [level, readyAt] of surfaceLevelReadyAt) { + if (draggingLevels.has(level) || wallsPending) surfaceLevelReadyAt.set(level, Infinity) + else if (readyAt === Infinity) surfaceLevelReadyAt.set(level, frameNow + NODE_BATCH_SETTLE_MS) + else if (frameNow >= readyAt) surfaceLevelReadyAt.delete(level) + } let changed = changedNodes.size > 0 @@ -353,6 +400,10 @@ export function runBatchFrame( // Overrides defer like tint/dirt — an in-flight gesture ends with a // commit whose mark re-offers the node; dropping it here would strand it. if ( + ((sceneNodes[nodeId as AnyNodeId]?.type === 'slab' || + sceneNodes[nodeId as AnyNodeId]?.type === 'ceiling') && + (wallsPending || + surfaceLevelReadyAt.has(sceneNodes[nodeId as AnyNodeId]!.parentId ?? ''))) || tinted.has(nodeId) || dirty.has(nodeId as AnyNodeId) || overrides.get(nodeId) !== undefined || @@ -402,7 +453,13 @@ export function runBatchFrame( // A new leftover may be the bucket-mate its peers were missing — re-offer // the whole set together next wave. if (newLeftovers) for (const nodeId of leftoverNodes) staleNodes.add(nodeId) - publishBatchStats() + if (deferred.size > 0) { + if (wakeRef.current) clearTimeout(wakeRef.current) + wakeRef.current = setTimeout(() => { + wakeRef.current = null + invalidate() + }, NODE_BATCH_SETTLE_MS + 20) + } } // The headless bake/thumbnail worker loads `?disable=draw` pages: one capture, diff --git a/packages/nodes/src/shared/node-batch/types.ts b/packages/nodes/src/shared/node-batch/types.ts index 519ab557b6..09595c2748 100644 --- a/packages/nodes/src/shared/node-batch/types.ts +++ b/packages/nodes/src/shared/node-batch/types.ts @@ -34,6 +34,8 @@ import type { BufferGeometry, Material, Matrix4, Mesh, Object3D } from 'three' export type BatchEntry = { nodeId: string levelId: string + /** Stable node/part identity for mutable surface geometry. */ + allocationKey?: string /** Source mesh in the node's mounted subtree; draw-hidden while batched. */ mesh: Mesh geometry: BufferGeometry @@ -59,6 +61,11 @@ export type NodeBatchStats = { batches: number instances: number nodes: number + releases: number + joins: number + geometryReplacements: number + overflowRebuilds: number + geometryBytesCopied: number } /** @@ -76,8 +83,14 @@ export type NodeBatchStoreApi = { * bookkeeping and wins nothing. */ join(candidates: BatchCandidate[], minEntriesForNewBatch: number): BatchEntry[] - /** Removes the node's instances and reveals nothing (caller reveals). */ + /** Hides draws immediately; deletion is coalesced by flushReleases. Caller reveals sources. */ release(nodeId: string): boolean + flushReleases(now?: number): void + pruneEmpty( + now?: number, + retainedLevels?: ReadonlySet, + earliestDisposalAt?: number, + ): boolean /** Drops batches orphaned by a level-subtree remount; returns their nodes. */ pruneDetached(): Set has(nodeId: string): boolean diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index 11e70e8609..09789931cd 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -313,16 +313,17 @@ export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapabil end() return null } - let restored = false - return () => { - if (restored) return - restored = true + let ended = false + const finish = (committed: boolean) => { + if (ended) return + ended = true try { - restore() + if (!committed) restore() } finally { end() } } + return Object.assign(() => finish(false), { commit: () => finish(true) }) }, getEffectiveMaterial: ({ node, role }) => { const ref = (node as SlotsNode).slots?.[role] diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index e5063b27ad..5cd30bd691 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -9,6 +9,7 @@ import { type LevelNode, type SiteNode, type SlabNode, + sceneRegistry, slabPolygonContextFromGeometry, surfaceHeightAt, terrainFieldOf, @@ -22,6 +23,7 @@ import { createSurfaceRoleMaterial, generateSlabGeometry, type RenderShading, + registerMaterialCacheCleanup, resolveMaterialRef, resolveSlotDefaultMaterial, } from '@pascal-app/viewer' @@ -57,6 +59,32 @@ type SlabMaterial = Material & { } const slabMaterialCache = new Map() +registerMaterialCacheCleanup(() => { + const previous = new Map([...slabMaterialCache].map(([key, material]) => [material, key])) + slabMaterialCache.clear() + // Cache clearing can be requested by an embed while sources and batches are mounted. + // Rebind both before disposing; only live signatures populate the fresh cache. + const visited = new Set() + for (const root of sceneRegistry.nodes.values()) { + root.traverse((object) => { + if (visited.has(object)) return + visited.add(object) + const mesh = object as Mesh + if (!mesh.isMesh) return + const replace = (material: Material) => { + const key = previous.get(material) + if (!key) return material + const { shading, ...node } = JSON.parse(key) + return getLegacySlabMaterial(node as SlabNode, shading) + } + mesh.material = Array.isArray(mesh.material) + ? mesh.material.map(replace) + : replace(mesh.material) + }) + } + for (const material of previous.keys()) material.dispose() +}) + function getSlabSlotMaterial( node: SlabNode, slotId: SlabSlotId, diff --git a/packages/viewer/src/components/viewer/perf-monitor.tsx b/packages/viewer/src/components/viewer/perf-monitor.tsx index 19aff71456..0bcf211a52 100644 --- a/packages/viewer/src/components/viewer/perf-monitor.tsx +++ b/packages/viewer/src/components/viewer/perf-monitor.tsx @@ -74,6 +74,7 @@ export const PerfMonitor = () => { // synthetic click on the node. useEffect(() => { const probe = { + batchStats: readPerfBatchStats, listNodes(type: string): string[] { return Object.values(useScene.getState().nodes) .filter((n) => n.type === type) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 5854e2b958..7346b7b3d2 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -125,6 +125,7 @@ export { MONO_PALETTE, PRESET_PALETTES, type RenderShading, + registerMaterialCacheCleanup, resolveMaterialRef, resolveSlotDefaultMaterial, resolveSurfaceColor, diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 20b252c043..5b9805b11a 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -788,6 +788,15 @@ export function disposeMaterial(material: THREE.Material): void { material.dispose() } +const materialCacheCleanups = new Set<() => void>() + +export function registerMaterialCacheCleanup(cleanup: () => void): () => void { + materialCacheCleanups.add(cleanup) + return () => { + materialCacheCleanups.delete(cleanup) + } +} + export function clearMaterialCache(): void { for (const material of materialCache.values()) { material.dispose() @@ -809,4 +818,5 @@ export function clearMaterialCache(): void { } textureCache.clear() textureLoadPromises.clear() + for (const cleanup of materialCacheCleanups) cleanup() } diff --git a/packages/viewer/src/lib/perf-panel-store.ts b/packages/viewer/src/lib/perf-panel-store.ts index ae189718c7..f39de7bae4 100644 --- a/packages/viewer/src/lib/perf-panel-store.ts +++ b/packages/viewer/src/lib/perf-panel-store.ts @@ -66,7 +66,16 @@ export function usePerfStats(): PerfStats | null { * looks no cheaper than an unbatched one — the saving is encode cost per * call, not call count. */ -export type PerfBatchStats = { items: number; instances: number; containers: number } +export type PerfBatchStats = { + items: number + instances: number + containers: number + releases?: number + joins?: number + geometryReplacements?: number + overflowRebuilds?: number + geometryBytesCopied?: number +} let batchStats: PerfBatchStats = { items: 0, instances: 0, containers: 0 } From e164a29c00b695a80377e421f10c69feecfbc890 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 8 Sep 2026 15:22:57 -0400 Subject: [PATCH 3/8] fix(editor): reconcile paint previews after apply exceptions --- .../editor/src/lib/paint-preview-owner.ts | 7 +- .../src/shared/node-batch/node-batch.test.ts | 84 ++++++++++++++++++- packages/nodes/src/shared/slot-paint.ts | 11 ++- 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/lib/paint-preview-owner.ts b/packages/editor/src/lib/paint-preview-owner.ts index e7f7befca2..f766157484 100644 --- a/packages/editor/src/lib/paint-preview-owner.ts +++ b/packages/editor/src/lib/paint-preview-owner.ts @@ -54,7 +54,12 @@ export function createPaintPreviewOwner() { try { interaction.apply!() } catch (error) { - end() + // A subscriber can throw after the scene write has already been published. + try { + end(true) + } catch { + // Preserve the apply error even if a hold listener also throws. + } throw error } end(true) diff --git a/packages/nodes/src/shared/node-batch/node-batch.test.ts b/packages/nodes/src/shared/node-batch/node-batch.test.ts index 80ac1c6d5a..d94f349d7d 100644 --- a/packages/nodes/src/shared/node-batch/node-batch.test.ts +++ b/packages/nodes/src/shared/node-batch/node-batch.test.ts @@ -27,7 +27,11 @@ import { commitPaintScopeFanout } from '../../../../editor/src/lib/paint-scope' import { applyShadowOnly, clearShadowOnly } from '../../../../viewer/src/lib/shadow-only' import { getCeilingMaterials } from '../../ceiling/materials' import { ceilingPaint } from '../../ceiling/paint' -import { createSlotPaintCapability, isSlotPaintPreviewActive } from '../slot-paint' +import { + createSlotPaintCapability, + isSlotPaintPreviewActive, + subscribeSlotPaintPreviews, +} from '../slot-paint' import { collectBatchCandidate, collectTintedNodes } from './candidates' import { NodeBatchStore } from './store' import { @@ -577,7 +581,7 @@ test('superseded, cancelled and failed paint interactions end holds once without }) failed.preview!() expect(() => failed.apply!()).toThrow('commit failed') - expect(restored).toBe(3) + expect(restored).toBe(2) expect(isSlotPaintPreviewActive('ceiling_0')).toBe(false) }) @@ -613,3 +617,79 @@ test('level remount releases orphaned draws and restores sources before collecti expect(batches(replacement)).toHaveLength(1) expect(meshes.every((mesh) => !mesh.layers.isEnabled(SCENE_LAYER))).toBe(true) }) + +test('paint apply throwing after publication ends fan-out holds without restoring and dirties every target', () => { + const { meshes } = setup() + const targets = ['ceiling_0', 'ceiling_1', 'ceiling_2'].map((nodeId) => ({ + nodeId, + role: 'surface', + })) + const interaction = createPaintPreviewOwner().wrap({ + key: 'fan-out', + preview: () => + combinePaintPreviews( + targets.map( + ({ nodeId, role }) => + ceilingPaint.applyPreview({ + node: useScene.getState().nodes[nodeId]!, + root: sceneRegistry.nodes.get(nodeId)!, + role, + material: { properties: { color: '#ff0000' } } as never, + materialPreset: undefined, + })!, + ), + ), + apply: () => commitPaintScopeFanout(targets as never, undefined, 'library:test/finish'), + })! + const cancel = interaction.preview!()! + const previews = meshes.slice(0, 3).map((mesh) => mesh.material) + const failure = new Error('subscriber failed after write') + const unsubscribeScene = useScene.subscribe((state, previous) => { + if (state.nodes !== previous.nodes) throw failure + }) + try { + expect(() => interaction.apply!()).toThrow(failure) + } finally { + unsubscribeScene() + } + cancel() + for (const { nodeId } of targets) { + expect( + (useScene.getState().nodes[nodeId] as AnyNode & { slots: Record }).slots + .surface, + ).toBe('library:test/finish') + expect(isSlotPaintPreviewActive(nodeId)).toBe(false) + expect(useScene.getState().dirtyNodes.has(nodeId as never)).toBe(true) + } + expect(meshes.slice(0, 3).map((mesh) => mesh.material)).toEqual(previews) +}) + +test('a throwing preview listener rolls back its hold before preview creation', () => { + setup() + const unsubscribePreview = subscribeSlotPaintPreviews(() => { + throw new Error('preview listener failed') + }) + let applied = false + const paint = createSlotPaintCapability({ + resolveRole: () => 'surface', + applyPreview: () => { + applied = true + return () => {} + }, + }) + try { + expect(() => + paint.applyPreview({ + node: useScene.getState().nodes.ceiling_0!, + root: sceneRegistry.nodes.get('ceiling_0')!, + role: 'surface', + material: undefined, + materialPreset: undefined, + }), + ).toThrow('preview listener failed') + expect(isSlotPaintPreviewActive('ceiling_0')).toBe(false) + expect(applied).toBe(false) + } finally { + unsubscribePreview() + } +}) diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index 09789931cd..dc5cbb2986 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -46,7 +46,13 @@ export function subscribeSlotPaintPreviews(listener: (nodeId: string) => void): function beginSlotPaintPreview(nodeId: string): () => void { const count = previewCounts.get(nodeId) ?? 0 previewCounts.set(nodeId, count + 1) - if (count === 0) for (const listener of previewListeners) listener(nodeId) + try { + if (count === 0) for (const listener of previewListeners) listener(nodeId) + } catch (error) { + if (count === 0) previewCounts.delete(nodeId) + else previewCounts.set(nodeId, count) + throw error + } return () => { const remaining = (previewCounts.get(nodeId) ?? 1) - 1 if (remaining > 0) previewCounts.set(nodeId, remaining) @@ -318,7 +324,8 @@ export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapabil if (ended) return ended = true try { - if (!committed) restore() + if (committed) useScene.getState().markDirty(args.node.id as AnyNodeId) + else restore() } finally { end() } From 65a8500208ef4d70c5b5508aff2a1820c12224b3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 8 Sep 2026 15:29:30 -0400 Subject: [PATCH 4/8] fix(nodes): rebuild slabs and release batches on material cache clear --- .../components/editor/selection-manager.tsx | 8 +- .../shared/node-batch/source-systems.test.ts | 194 ++++++++++++++++-- .../nodes/src/shared/node-batch/system.tsx | 7 + packages/nodes/src/shared/slot-paint.ts | 22 +- packages/nodes/src/slab/geometry.ts | 30 +-- packages/viewer/src/lib/materials.ts | 40 ++-- 6 files changed, 246 insertions(+), 55 deletions(-) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 26661e93b4..7c79ef0cf5 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -31,6 +31,7 @@ import { createMaterial, createMaterialFromPresetRef, getRoofMaterialArray, + registerMaterialCacheCleanup, useViewer, } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' @@ -2285,7 +2286,7 @@ const SelectionMaterialSync = () => { }, []) useEffect(() => { - return () => { + const clearHighlights = () => { for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) { if (mesh.material === entry.highlightedMaterial) { mesh.material = entry.originalMaterial @@ -2295,6 +2296,11 @@ const SelectionMaterialSync = () => { highlightedMaterialsRef.current.clear() } + const unsubscribe = registerMaterialCacheCleanup(clearHighlights) + return () => { + unsubscribe() + clearHighlights() + } }, []) return null diff --git a/packages/nodes/src/shared/node-batch/source-systems.test.ts b/packages/nodes/src/shared/node-batch/source-systems.test.ts index 66fc9d0156..806774b108 100644 --- a/packages/nodes/src/shared/node-batch/source-systems.test.ts +++ b/packages/nodes/src/shared/node-batch/source-systems.test.ts @@ -28,7 +28,7 @@ function runSourceTest(body: string) { test('real slab top/side/skirt collection, shared defaults, transparent overrides and cache ownership', () => { runSourceTest(` const sourceMaterials = await import('./packages/viewer/src/lib/materials.ts') - mock.module('@pascal-app/viewer', () => ({ ...viewer, registerMaterialCacheCleanup: sourceMaterials.registerMaterialCacheCleanup, resolveSlotDefaultMaterial: sourceMaterials.resolveSlotDefaultMaterial })) + mock.module('@pascal-app/viewer', () => ({ ...viewer, ...sourceMaterials })) const { buildSlabGeometry } = await import('./packages/nodes/src/slab/geometry.ts') const { collectBatchCandidate } = await import('./packages/nodes/src/shared/node-batch/candidates.ts') const { disposeObject3DResources } = await import('./packages/viewer/src/lib/dispose-object3d.ts') @@ -77,20 +77,16 @@ test('real slab top/side/skirt collection, shared defaults, transparent override disposeObject3DResources(legacyFirst) assert.equal(disposed, 0) assert.equal(top.transparent, false) - const { BatchedMesh } = await import('three') - const batch = new BatchedMesh(4, 1024, 1024, top) - root.add(legacySecond, batch) - let reboundBeforeDispose = false - top.addEventListener('dispose', () => { reboundBeforeDispose = legacySecond.children[0].material !== top }) + const { flushGlobalEffects } = await import('@react-three/fiber') sourceMaterials.clearMaterialCache() - assert.equal(reboundBeforeDispose, true) - assert.notEqual(legacySecond.children[0].material, top) - assert.equal(batch.material, legacySecond.children[0].material) - assert.equal(buildSlabGeometry(legacy, ctx, 'solid').children[0].material, legacySecond.children[0].material) + assert.equal(disposed, 0) + assert(core.useScene.getState().dirtyNodes.has(slab.id)) + const replacement = buildSlabGeometry(legacy, ctx, 'solid') + assert.notEqual(replacement.children[0].material, top) + assert.notEqual(replacement.children[1].material, side) + assert.equal(buildSlabGeometry(legacy, ctx, 'solid').children[0].material, replacement.children[0].material) + flushGlobalEffects('after', 0) assert.equal(disposed, 2) - root.remove(legacySecond, batch) - sourceMaterials.clearMaterialCache() - assert.notEqual(buildSlabGeometry(legacy, ctx, 'solid').children[0].material, legacySecond.children[0].material) assert.notEqual(sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'solid', 0.8), sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'rendered', 0.8)) assert.notEqual(sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'rendered', 0.8), sourceMaterials.resolveSlotDefaultMaterial('#cccccc', 'rendered', 0.4)) `) @@ -160,3 +156,175 @@ test('priority-1 dirty snapshot sees the priority-2 ceiling rebuild and batches for (const ref of refs) if (ref.current) clearTimeout(ref.current) `) }) + +const slabCacheFixture = ` + const sourceMaterials = await import('./packages/viewer/src/lib/materials.ts') + const scene = core.useScene + const viewerStore = viewer.useViewer + viewerStore.setState({ bumpGeometryRevision: () => viewerStore.setState({ geometryRevision: viewerStore.getState().geometryRevision + 1 }) }) + const selectorHook = (store) => Object.assign((selector) => selector(store.getState()), store) + mock.module('@pascal-app/core', () => ({ ...core, useScene: selectorHook(scene), useRegistryVersion: () => 0 })) + mock.module('@pascal-app/viewer', () => ({ ...viewer, ...sourceMaterials, useViewer: selectorHook(viewerStore) })) + mock.module('./packages/viewer/src/store/use-viewer.ts', () => ({ default: selectorHook(viewerStore) })) + const fiber = await import('@react-three/fiber') + const frames = [] + mock.module('@react-three/fiber', () => ({ ...fiber, useThree: (selector) => selector({ gl: { domElement: {} }, invalidate: () => {} }), useFrame: (callback, priority) => frames.push({ callback, priority }) })) + const react = await import('react') + let effects = [] + let refs = [] + let refIndex = 0 + const hooks = { useEffect: (effect) => effects.push(effect), useCallback: (callback) => callback, useRef: (value) => refs[refIndex++] ??= { current: value }, useSyncExternalStore: (_, snapshot) => snapshot(), useDebugValue: () => {} } + mock.module('react', () => ({ ...react, ...hooks, default: { ...react.default, ...hooks } })) + const { buildSlabGeometry } = await import('./packages/nodes/src/slab/geometry.ts') + const { GeometrySystem } = await import('./packages/viewer/src/systems/geometry/geometry-system.tsx') + const { captureChangedNodes, runBatchFrame, subscribeBatchInteractions, resetNodeBatchState } = await import('./packages/nodes/src/shared/node-batch/system.tsx') + const preset = { ...core.MATERIAL_CATALOG[0], id: 'slab-cache-fixture', preset: { ...core.MATERIAL_CATALOG[0].preset, maps: {} } } + core.registerLibraryMaterials([preset]) + core.registerNode({ kind: 'slab', schemaVersion: 1, schema: core.SlabNode, geometry: buildSlabGeometry, capabilities: {} }) + const level = core.LevelNode.parse({ id: 'level_test', children: ['slab_0', 'slab_1', 'slab_2'] }) + const nodes = { [level.id]: level } + const root = new Group() + core.sceneRegistry.nodes.set(level.id, root) + core.sceneRegistry.byType.level.add(level.id) + const slabs = Array.from({ length: 3 }, (_, i) => { + const node = core.SlabNode.parse({ id: 'slab_' + i, parentId: level.id, materialPreset: 'library:slab-cache-fixture', polygon: [[0,0],[2,0],[2,2],[0,2]] }) + nodes[node.id] = node + const group = new Group() + root.add(group) + core.sceneRegistry.nodes.set(node.id, group) + core.sceneRegistry.byType.slab.add(node.id) + return group + }) + scene.setState({ nodes, dirtyNodes: new Set(level.children), materials: {} }) + viewerStore.setState({ shading: 'solid', textures: true, externalSelectedIds: [], previewSelectedIds: [], hoveredId: null, selection: { ...viewerStore.getState().selection, selectedIds: [], levelId: null } }) + GeometrySystem() + effects = []; refs = []; refIndex = 0 + const rebuild = frames[0].callback + const unsubscribeBatch = subscribeBatchInteractions(() => {}) + let now = 0 + performance.now = () => now + const wakeRef = { current: null } + const frame = () => { + captureChangedNodes() + rebuild() + runBatchFrame(() => {}, wakeRef) + fiber.flushGlobalEffects('after', now) + } + const settle = () => { frame(); now += 181; frame() } + const batches = () => root.children.filter((child) => child.name === 'item-batch') + const dispose = () => { + unsubscribeBatch() + resetNodeBatchState() + if (wakeRef.current) clearTimeout(wakeRef.current) + } + frame() +` + +test('cache clear releases real slab batches and a single moved slab rejoins its peers under the fresh material key', () => { + runSourceTest( + slabCacheFixture + + ` + settle() + assert.equal(batches().length, 2) + assert(batches().every((batch) => batch.instanceCount === 3)) + const oldTop = slabs[0].children[0].material + const oldSide = slabs[0].children[1].material + const disposed = new Set() + for (const material of [oldTop, oldSide]) material.addEventListener('dispose', () => { + assert.equal(batches().some((batch) => batch.material === material), false) + assert(slabs.every((slab) => slab.children.every((mesh) => mesh.material !== material))) + disposed.add(material) + }) + sourceMaterials.clearMaterialCache() + assert.equal(disposed.size, 0) + assert.equal(batches().length, 0) + assert(level.children.every((id) => scene.getState().dirtyNodes.has(id))) + frame() + assert.equal(disposed.size, 2) + settle() + const top = slabs[0].children[0].material + const batch = batches().find((batch) => batch.material === top) + assert(batch) + assert.equal(batch.instanceCount, 3) + core.useLiveTransforms.getState().set('slab_0', { position: [4,0,0], rotation: 0 }) + slabs[0].position.x = 4 + frame() + assert.equal(batch.instanceCount, 2) + core.useLiveTransforms.getState().clear('slab_0') + settle() + assert.equal(batches().find((container) => container.material === top), batch) + assert.equal(batch.instanceCount, 3) + assert.equal(batches().length, 2) + assert(slabs.every((slab) => slab.children.every((mesh) => !mesh.layers.isEnabled(viewer.SCENE_LAYER)))) + dispose() + `, + ) +}) + +test('selected legacy slab cache clear invalidates saved originals before disposal and deselect keeps current cached materials', () => { + runSourceTest( + slabCacheFixture + + ` + const { SelectionManager } = await import('./packages/editor/src/components/editor/selection-manager.tsx') + const SelectionMaterialSync = SelectionManager().props.children[1].type + effects = []; refs = []; refIndex = 0 + viewerStore.setState({ selection: { ...viewerStore.getState().selection, selectedIds: ['slab_0'] } }) + const oldMesh = slabs[0].children[0] + const original = oldMesh.material + let disposed = false + original.addEventListener('dispose', () => { disposed = true }) + let assigned = original + Object.defineProperty(oldMesh, 'material', { get: () => assigned, set: (material) => { + assert(!(disposed && material === original), 'must never restore a disposed saved original') + assigned = material + } }) + SelectionMaterialSync() + const cleanups = effects.map((effect) => effect()).filter(Boolean) + assert.notEqual(oldMesh.material, original) + sourceMaterials.clearMaterialCache() + assert.equal(disposed, false) + frame() + assert.equal(disposed, true) + const current = slabs[0].children[0].material + assert.notEqual(current, original) + let currentDisposed = false + current.addEventListener('dispose', () => { currentDisposed = true }) + viewerStore.setState({ selection: { ...viewerStore.getState().selection, selectedIds: [] } }) + effects = []; refIndex = 0 + SelectionMaterialSync() + effects[0]() + assert.equal(slabs[0].children[0].material, current) + assert.equal(currentDisposed, false) + assert.equal(buildSlabGeometry(nodes.slab_0, { parent: level, children: [], siblings: [], resolve: (id) => nodes[id] }, 'solid').children[0].material, current) + for (const cleanup of cleanups) cleanup() + dispose() + `, + ) +}) + +test('paint cancellation after cache clear never restores a disposed legacy slab reference', () => { + runSourceTest( + slabCacheFixture + + ` + const { slabPaint } = await import('./packages/nodes/src/slab/paint.ts') + const oldMesh = slabs[0].children[0] + const original = oldMesh.material + let disposed = false + original.addEventListener('dispose', () => { disposed = true }) + let assigned = original + Object.defineProperty(oldMesh, 'material', { get: () => assigned, set: (material) => { + assert(!(disposed && material === original), 'must never restore a disposed preview original') + assigned = material + } }) + const cancel = slabPaint.applyPreview({ node: nodes.slab_0, root: slabs[0], role: 'surface', material: { properties: { color: '#ff0000' } }, materialPreset: undefined }) + assert(cancel) + sourceMaterials.clearMaterialCache() + frame() + assert.equal(disposed, true) + const current = slabs[0].children[0].material + cancel() + assert.equal(slabs[0].children[0].material, current) + dispose() + `, + ) +}) diff --git a/packages/nodes/src/shared/node-batch/system.tsx b/packages/nodes/src/shared/node-batch/system.tsx index edcab8f1f3..1eb55c9b54 100644 --- a/packages/nodes/src/shared/node-batch/system.tsx +++ b/packages/nodes/src/shared/node-batch/system.tsx @@ -13,6 +13,7 @@ import { getPendingWallRebuildCount, isIsolationActive, publishPerfBatchStats, + registerMaterialCacheCleanup, useViewer, } from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' @@ -185,9 +186,15 @@ export function subscribeBatchInteractions(invalidate: () => void): () => void { } }) const unsubscribePreviews = subscribeSlotPaintPreviews(changed) + const unsubscribeMaterials = registerMaterialCacheCleanup(() => { + releaseAll() + for (const nodeId of getBatchableNodeIds()) changedNodes.add(nodeId) + invalidate() + }) return () => { unsubscribeTransforms() unsubscribePreviews() + unsubscribeMaterials() } } diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index dc5cbb2986..a0ef150a62 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -14,7 +14,12 @@ import { toSceneMaterialRef, useScene, } from '@pascal-app/core' -import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer' +import { + createMaterial, + createMaterialFromPresetRef, + registerMaterialCacheCleanup, + useViewer, +} from '@pascal-app/viewer' import { type Material, type Mesh, type Object3D, Raycaster } from 'three' /** @@ -29,6 +34,11 @@ import { type Material, type Mesh, type Object3D, Raycaster } from 'three' * those are injected per kind. */ +let materialCacheGeneration = 0 +registerMaterialCacheCleanup(() => { + materialCacheGeneration++ +}) + const previewCounts = new Map() const previewListeners = new Set<(nodeId: string) => void>() @@ -201,6 +211,7 @@ export function previewGeometrySlot(args: PaintPreviewArgs): (() => void) | null const preview = buildSlotPreviewMaterial(material, materialPreset) if (!preview) return () => {} + const generation = materialCacheGeneration const restores: Array<() => void> = [] ;(root as Object3D).traverse((object) => { const mesh = object as Mesh @@ -217,6 +228,10 @@ export function previewGeometrySlot(args: PaintPreviewArgs): (() => void) | null if (restores.length === 0) return null return () => { + if (generation !== materialCacheGeneration) { + useScene.getState().markDirty(args.node.id as AnyNodeId) + return + } for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() } } @@ -231,6 +246,7 @@ export function previewSlotByUserData(args: PaintPreviewArgs): (() => void) | nu const preview = buildSlotPreviewMaterial(material, materialPreset) if (!preview) return () => {} + const generation = materialCacheGeneration const restores: Array<() => void> = [] ;(root as Object3D).traverse((object) => { const mesh = object as Mesh @@ -245,6 +261,10 @@ export function previewSlotByUserData(args: PaintPreviewArgs): (() => void) | nu if (restores.length === 0) return null return () => { + if (generation !== materialCacheGeneration) { + useScene.getState().markDirty(args.node.id as AnyNodeId) + return + } for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() } } diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 5cd30bd691..cd1821721f 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -9,10 +9,10 @@ import { type LevelNode, type SiteNode, type SlabNode, - sceneRegistry, slabPolygonContextFromGeometry, surfaceHeightAt, terrainFieldOf, + useScene, } from '@pascal-app/core' import { applyMaterialPresetToMaterials, @@ -60,29 +60,15 @@ type SlabMaterial = Material & { const slabMaterialCache = new Map() registerMaterialCacheCleanup(() => { - const previous = new Map([...slabMaterialCache].map(([key, material]) => [material, key])) + const previous = [...slabMaterialCache.values()] slabMaterialCache.clear() - // Cache clearing can be requested by an embed while sources and batches are mounted. - // Rebind both before disposing; only live signatures populate the fresh cache. - const visited = new Set() - for (const root of sceneRegistry.nodes.values()) { - root.traverse((object) => { - if (visited.has(object)) return - visited.add(object) - const mesh = object as Mesh - if (!mesh.isMesh) return - const replace = (material: Material) => { - const key = previous.get(material) - if (!key) return material - const { shading, ...node } = JSON.parse(key) - return getLegacySlabMaterial(node as SlabNode, shading) - } - mesh.material = Array.isArray(mesh.material) - ? mesh.material.map(replace) - : replace(mesh.material) - }) + const state = useScene.getState() + for (const node of Object.values(state.nodes)) { + if (node.type === 'slab') state.markDirty(node.id as AnyNodeId) + } + return () => { + for (const material of previous) material.dispose() } - for (const material of previous.keys()) material.dispose() }) function getSlabSlotMaterial( diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 5b9805b11a..c0c7ddae42 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -10,6 +10,7 @@ import { type SceneMaterialId, type SurfaceRole, } from '@pascal-app/core' +import { addAfterEffect, invalidate } from '@react-three/fiber' import * as THREE from 'three' import { float, mix, positionViewDirection, transformedNormalView } from 'three/tsl' import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' @@ -788,9 +789,10 @@ export function disposeMaterial(material: THREE.Material): void { material.dispose() } -const materialCacheCleanups = new Set<() => void>() +type MaterialCacheCleanup = (() => void) | (() => () => void) +const materialCacheCleanups = new Set() -export function registerMaterialCacheCleanup(cleanup: () => void): () => void { +export function registerMaterialCacheCleanup(cleanup: MaterialCacheCleanup): () => void { materialCacheCleanups.add(cleanup) return () => { materialCacheCleanups.delete(cleanup) @@ -798,25 +800,27 @@ export function registerMaterialCacheCleanup(cleanup: () => void): () => void { } export function clearMaterialCache(): void { - for (const material of materialCache.values()) { - material.dispose() - } + const previous = [ + ...materialCache.values(), + ...defaultMaterialCache.values(), + ...surfaceRoleMaterialCache.values(), + ...textureCache.values(), + ] materialCache.clear() - - for (const material of defaultMaterialCache.values()) { - material.dispose() - } defaultMaterialCache.clear() - - for (const material of surfaceRoleMaterialCache.values()) { - material.dispose() - } surfaceRoleMaterialCache.clear() - - for (const texture of textureCache.values()) { - texture.dispose() - } textureCache.clear() textureLoadPromises.clear() - for (const cleanup of materialCacheCleanups) cleanup() + const disposals: Array<() => void> = [] + for (const cleanup of materialCacheCleanups) { + const dispose = cleanup() + if (dispose) disposals.push(dispose) + } + // Consumers must rebuild sources and release batches before the old caches die. + const unsubscribe = addAfterEffect(() => { + unsubscribe() + for (const resource of previous) resource.dispose() + for (const dispose of disposals) dispose() + }) + invalidate() } From a7ad5c166c11b0b727288d230ac3b576471cc941 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 8 Sep 2026 15:30:31 -0400 Subject: [PATCH 5/8] fix(nodes): strip the merged wall batch from GLB exports --- packages/nodes/src/wall/wall-batch-system.test.ts | 5 +++++ packages/nodes/src/wall/wall-batch-system.tsx | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts index 5bbde02efa..8e1c7cb6bb 100644 --- a/packages/nodes/src/wall/wall-batch-system.test.ts +++ b/packages/nodes/src/wall/wall-batch-system.test.ts @@ -259,3 +259,8 @@ describe('WallBatchSystem capture holds', () => { expect(walls.every((wall) => !wall.layers.isEnabled(SCENE_LAYER))).toBe(true) }) }) + +test('merged wall batches are stripped from GLB exports', () => { + const { batch } = setupBatchedLevel() + expect(batch.userData.pascalExport).toBe('strip') +}) diff --git a/packages/nodes/src/wall/wall-batch-system.tsx b/packages/nodes/src/wall/wall-batch-system.tsx index ba04024102..e9a366a89d 100644 --- a/packages/nodes/src/wall/wall-batch-system.tsx +++ b/packages/nodes/src/wall/wall-batch-system.tsx @@ -310,6 +310,7 @@ function mergeLevel(levelId: string, excludedNodeIds: ReadonlySet = EMPT const mesh = new Mesh(batch.geometry, candidates[0]!.materials) mesh.name = 'wall-batch' + mesh.userData.pascalExport = 'strip' mesh.castShadow = true mesh.receiveShadow = true mesh.matrixAutoUpdate = false From 764a52cf554c21b47a32d923f4539db827807a41 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 8 Sep 2026 15:31:36 -0400 Subject: [PATCH 6/8] fix(editor): include moved node identity in perf receipts --- packages/editor/src/store/use-interaction-scope.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts index b5c41bc639..69c97cb164 100644 --- a/packages/editor/src/store/use-interaction-scope.ts +++ b/packages/editor/src/store/use-interaction-scope.ts @@ -67,7 +67,7 @@ function beginScopePerfAction(scope: ActiveInteractionScope): void { if (hasUncommittedPerfAction()) return switch (scope.kind) { case 'moving': - scopePerfActionId = beginPerfAction('drag:move', scope.nodeType) + scopePerfActionId = beginPerfAction('drag:move', scope.nodeId) break case 'placing': scopePerfActionId = beginPerfAction( From dd745a0edc286a5c8e80265af83cd9fca1ca402c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 8 Sep 2026 16:31:45 -0400 Subject: [PATCH 7/8] fix(editor): preserve grid surface hits while batching --- .../editor/src/hooks/use-grid-events.test.ts | 135 +++++++++++++++++- packages/editor/src/hooks/use-grid-events.ts | 3 +- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/hooks/use-grid-events.test.ts b/packages/editor/src/hooks/use-grid-events.test.ts index 2cc6fc09d6..f8ccf91297 100644 --- a/packages/editor/src/hooks/use-grid-events.test.ts +++ b/packages/editor/src/hooks/use-grid-events.test.ts @@ -1,9 +1,27 @@ import { expect, spyOn, test } from 'bun:test' -import { emitter, type GridEvent } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { + CeilingNode, + emitter, + type GridEvent, + nodeRegistry, + registerNode, + SlabNode, + useRegistry, + WallNode, +} from '@pascal-app/core' +import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer' import { _roots, act, createRoot } from '@react-three/fiber' import { createElement } from 'react' -import { PerspectiveCamera, type WebGLRenderer } from 'three' +import { + DoubleSide, + Mesh, + MeshBasicMaterial, + PerspectiveCamera, + PlaneGeometry, + type WebGLRenderer, +} from 'three' +import { DRAFTING_SURFACE_EXTENSION_KEY } from '../lib/interaction/registered-drafting' +import useInteractionScope from '../store/use-interaction-scope' import { useGridEvents } from './use-grid-events' test('grid moves throttle camera drags at 100 ms and resume immediately during tool drags', async () => { @@ -109,3 +127,114 @@ test('grid moves throttle camera drags at 100 ms and resume immediately during t actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct } }) + +test.each([ + 'slab', + 'ceiling', +] as const)('grid moves keep the same %s hit when its source joins or leaves a batch', async (kind) => { + const previousViewer = useViewer.getState() + const previousScope = useInteractionScope.getState().scope + const restoreRegistry = nodeRegistry._snapshot() + const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT + actGlobal.IS_REACT_ACT_ENVIRONMENT = true + const canvas = Object.assign(new EventTarget(), { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + }) as unknown as HTMLCanvasElement + const root = createRoot(canvas) + const camera = new PerspectiveCamera(60, 1, 0.1, 100) + camera.position.set(0, kind === 'ceiling' ? 1 : 10, 10) + camera.lookAt(0, 5, 0) + camera.updateMatrixWorld() + const surface = new Mesh( + new PlaneGeometry(20, 20).rotateX(-Math.PI / 2), + new MeshBasicMaterial({ side: DoubleSide }), + ) + surface.position.y = 5 + surface.updateMatrixWorld(true) + const surfaceId = `${kind}_grid_batch_test` as const + const wall = WallNode.parse({ start: [0, 0], end: [0, 2] }) + const delivered: GridEvent[] = [] + const onMove = (event: GridEvent) => delivered.push(event) + emitter.on('grid:move', onMove) + + function Grid() { + useRegistry(surfaceId, kind, { current: surface }) + useGridEvents(0) + return null + } + const send = () => { + const before = delivered.length + canvas.dispatchEvent( + Object.assign(new Event('pointermove'), { clientX: 55, clientY: 50, button: 0 }), + ) + expect(delivered).toHaveLength(before + 1) + return delivered.at(-1)! + } + + try { + nodeRegistry._reset() + registerNode({ + kind, + schemaVersion: 1, + category: 'structure', + capabilities: {}, + schema: kind === 'ceiling' ? CeilingNode : SlabNode, + defaults: () => ({}), + drafting: { surfaceQuery: true }, + extensions: { + [DRAFTING_SURFACE_EXTENSION_KEY]: { + kind, + ...(kind === 'ceiling' ? { raycast: 'underside' } : {}), + }, + }, + }) + useViewer.setState({ + cameraDragging: false, + selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, + }) + await root.configure({ + gl: { + domElement: canvas, + render() {}, + setSize() {}, + setPixelRatio() {}, + } as unknown as WebGLRenderer, + camera, + frameloop: 'never', + dpr: 1, + size: { width: 100, height: 100, top: 0, left: 0 }, + }) + for (const scope of [ + { kind: 'moving', node: wall, nodeId: wall.id, nodeType: 'wall', view: '3d' }, + { kind: 'drafting', tool: kind }, + ] as const) { + await act(async () => { + useInteractionScope.setState({ scope }) + root.render(createElement(Grid)) + }) + const before = send() + expect(before.surfaceHit?.hostId).toBe(surfaceId) + expect(before.position[1]).toBeCloseTo(5) + hideFromScene(surface, 'batched') + const batched = send() + expect(batched.surfaceHit).toEqual(before.surfaceHit) + expect(batched.position).toEqual(before.position) + expect(batched.localPosition).toEqual(before.localPosition) + showInScene(surface, 'batched') + expect(send().position).toEqual(before.position) + } + } finally { + await act(async () => { + root.render(null) + }) + emitter.off('grid:move', onMove) + _roots.delete(canvas) + surface.geometry.dispose() + surface.material.dispose() + restoreRegistry() + useInteractionScope.setState({ scope: previousScope }) + useViewer.setState(previousViewer) + actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct + } +}) diff --git a/packages/editor/src/hooks/use-grid-events.ts b/packages/editor/src/hooks/use-grid-events.ts index 9cd971e428..3e4fd8802e 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -7,7 +7,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { timeSpan, useViewer } from '@pascal-app/viewer' +import { setSurfaceRaycastLayers, timeSpan, useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { Matrix3, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' @@ -59,6 +59,7 @@ export function useGridEvents(gridY: number) { useEffect(() => { const canvas = gl.domElement + setSurfaceRaycastLayers(raycaster.current.layers) const getSurfaceIntersection = (): GridIntersection | null => { let closest: GridIntersection | null = null From 7a64290f573a374a64d1c4a780f9ca07729339c6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 8 Sep 2026 16:46:18 -0400 Subject: [PATCH 8/8] fix: preserve batched surfaces in geometry raycasts --- .../editor/first-person-controls.tsx | 3 + .../first-person/build-collider-world.test.ts | 30 +++++ .../systems/roof/roof-edit-system.test.tsx | 120 +++++++++++++++++ .../tools/shared/pointer-support-cap.test.ts | 82 +++++++++++- .../tools/shared/pointer-support-cap.ts | 3 +- .../src/hooks/use-ceiling-events.test.ts | 122 ++++++++++++++++++ packages/nodes/src/shared/accessory-cursor.ts | 2 + packages/nodes/src/shared/slot-paint.ts | 2 + .../shared/surface-raycast-batching.test.ts | 103 +++++++++++++++ packages/nodes/src/wall/paint.ts | 2 + 10 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/components/systems/roof/roof-edit-system.test.tsx create mode 100644 packages/editor/src/hooks/use-ceiling-events.test.ts create mode 100644 packages/nodes/src/shared/surface-raycast-batching.test.ts diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index df455444e2..f05e375959 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -40,6 +40,7 @@ import { STAND_CAPSULE, STAND_CLEARANCE, STAND_FLOAT_HEIGHT, + setSurfaceRaycastLayers, useViewer, WALKTHROUGH_FOV, } from '@pascal-app/viewer' @@ -156,6 +157,7 @@ const standClearanceRaycaster = new Raycaster() const standClearanceUp = new Vector3(0, 1, 0) const centerScreenPoint = new Vector2(0, 0) const doorInteractionRaycaster = new Raycaster() +setSurfaceRaycastLayers(doorInteractionRaycaster.layers) const doorLeafBox = new Box3() const doorLeafInverseMatrix = new Matrix4() const doorLeafLocalHit = new Vector3() @@ -178,6 +180,7 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false }) const spawnWorldPosition = new Vector3() const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const windowInteractionRaycaster = new Raycaster() +setSurfaceRaycastLayers(windowInteractionRaycaster.layers) const hudBuildingLocalEyePosition = new Vector3() const hudWorldEyePosition = new Vector3() const hudLevelBounds = new Box3() diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index ed398e69c2..94eaf59186 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -15,9 +15,11 @@ import { registerNode, ShelfNode, SiteNode, + SlabNode, sceneRegistry, useScene, } from '@pascal-app/core' +import { hideFromScene, STAND_CLEARANCE, showInScene } from '@pascal-app/viewer' import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world' @@ -91,6 +93,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { world?.dispose() }) + test('standing clearance and floor hits survive a slab source joining and leaving a batch', () => { + registerColliderDefinition('slab', SlabNode, 'structure', 'floor') + const slab = SlabNode.parse({ id: 'slab_clearance_batch', polygon: [] }) + setSceneNodes([slab]) + mountNode(slab, [4, 0.2, 4], [0, 2, 0]) + const source = sceneRegistry.nodes.get(slab.id)!.children[0] as Mesh + const raycaster = new Raycaster() + for (const batched of [false, true, false]) { + if (batched) hideFromScene(source, 'batched') + else showInScene(source, 'batched') + const world = buildFirstPersonColliderWorldFromRegistry()! + expect(world).not.toBeNull() + try { + raycaster.set(new Vector3(0, 3, 0), new Vector3(0, -1, 0)) + raycaster.far = STAND_CLEARANCE + expect(raycaster.intersectObject(world.mesh, false)[0]!.point.y).toBeCloseTo(2.1) + raycaster.set(new Vector3(0, 1, 0), new Vector3(0, 1, 0)) + expect(raycaster.intersectObjects([world.mesh], false)[0]!.point.y).toBeCloseTo(1.9) + raycaster.set(new Vector3(0, 2.5, 0), new Vector3(0, 1, 0)) + expect(raycaster.intersectObjects([world.mesh], false)).toHaveLength(0) + } finally { + world.dispose() + } + } + source.geometry.dispose() + ;(source.material as MeshBasicMaterial).dispose() + }) + test('excludes ceiling surfaces so the walkthrough player passes through them', () => { registerColliderDefinition('column', ColumnNode, 'structure') registerColliderDefinition('ceiling', CeilingNode, 'structure', 'ceiling') diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.test.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.test.tsx new file mode 100644 index 0000000000..2b65b52f66 --- /dev/null +++ b/packages/editor/src/components/systems/roof/roof-edit-system.test.tsx @@ -0,0 +1,120 @@ +import { expect, test } from 'bun:test' +import { RoofSegmentNode, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer' +import { _roots, act, createRoot, extend, type Instance, type ThreeEvent } from '@react-three/fiber' +import { createElement } from 'react' +import * as THREE from 'three' +import useInteractionScope from '../../../store/use-interaction-scope' +import { RoofEditSystem } from './roof-edit-system' + +extend({ Group: THREE.Group, Mesh: THREE.Mesh, LineSegments: THREE.LineSegments }) + +test('roof trim drag keeps its plane hit over a surface joining and leaving a batch', async () => { + const previousViewer = useViewer.getState() + const previousScene = useScene.getState() + const previousScope = useInteractionScope.getState().scope + const previousOverrides = useLiveNodeOverrides.getState() + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document') + const events = Object.assign(new EventTarget(), { setTimeout, clearTimeout }) + Object.defineProperty(globalThis, 'window', { configurable: true, value: events }) + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { body: { style: { cursor: '' } } }, + }) + const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT + actGlobal.IS_REACT_ACT_ENVIRONMENT = true + const canvas = Object.assign(new EventTarget(), { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + }) as unknown as HTMLCanvasElement + const root = createRoot(canvas) + const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100) + camera.position.set(0, 15, 10) + camera.lookAt(0, 3, 0) + camera.updateMatrixWorld() + const scene = new THREE.Scene() + const segment = RoofSegmentNode.parse({ + id: 'rseg_batch_drag', + width: 10, + depth: 10, + metadata: { showTrimPlanes: true }, + }) + const source = new THREE.Group() + const surface = new THREE.Mesh(new THREE.BoxGeometry(30, 0.25, 30), new THREE.MeshBasicMaterial()) + surface.position.y = 2 + scene.add(surface) + scene.updateMatrixWorld(true) + try { + sceneRegistry.nodes.set(segment.id, source) + useScene.setState({ nodes: { [segment.id]: segment }, readOnly: false }) + useViewer.setState({ + hoveredId: null, + selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [segment.id] }, + }) + await root.configure({ + gl: { + domElement: canvas, + render() {}, + setSize() {}, + setPixelRatio() {}, + } as unknown as THREE.WebGLRenderer, + camera, + scene, + frameloop: 'never', + dpr: 1, + size: { width: 100, height: 100, top: 0, left: 0 }, + }) + await act(async () => { + root.render(createElement(RoofEditSystem)) + }) + let pointerDown: ((event: ThreeEvent) => void) | undefined + scene.traverse((object) => { + const instance = (object as THREE.Object3D & { __r3f?: Instance }).__r3f + pointerDown ??= instance?.handlers.onPointerDown + }) + expect(pointerDown).toBeDefined() + await act(async () => { + pointerDown!({ + button: 0, + clientX: 50, + clientY: 50, + stopPropagation() {}, + } as ThreeEvent) + }) + const move = async () => { + await act(async () => { + events.dispatchEvent(Object.assign(new Event('pointermove'), { clientX: 60, clientY: 60 })) + }) + return useLiveNodeOverrides.getState().overrides.get(segment.id)?.trim + } + const before = await move() + expect(before).toBeDefined() + expect(Object.values(before!).some((value) => (value as number) > 0)).toBe(true) + hideFromScene(surface, 'batched') + expect(await move()).toEqual(before) + showInScene(surface, 'batched') + expect(await move()).toEqual(before) + await act(async () => { + events.dispatchEvent(new Event('pointercancel')) + }) + expect(useLiveNodeOverrides.getState().overrides.has(segment.id)).toBe(false) + } finally { + await act(async () => { + root.render(null) + }) + _roots.delete(canvas) + sceneRegistry.nodes.delete(segment.id) + surface.geometry.dispose() + surface.material.dispose() + useScene.setState(previousScene) + useViewer.setState(previousViewer) + useInteractionScope.setState({ scope: previousScope }) + useLiveNodeOverrides.setState(previousOverrides) + actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) + else Reflect.deleteProperty(globalThis, 'window') + if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument) + else Reflect.deleteProperty(globalThis, 'document') + } +}) diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts index 0ed0b57a95..9f7904c742 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts @@ -4,6 +4,7 @@ import { type AnyNodeDefinition, type AnyNodeId, getWallBaseElevationForNodes, + ItemNode, nodeRegistry, registerNode, type SlabNode, @@ -11,7 +12,7 @@ import { spatialGridManager, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer' import { BoxGeometry, Mesh, @@ -160,6 +161,85 @@ describe('resolvePointerSupportSurface node tops', () => { expect(support?.worldPoint).toEqual([0, 2, 0]) }) + test('keeps an unhovered item top above a batched slab across batch transitions', () => { + const restoreRegistry = nodeRegistry._snapshot() + const item = ItemNode.parse({ + id: 'item_support_batch', + parentId: LEVEL_ID, + asset: { + id: 'table', + name: 'Table', + src: '/table.glb', + category: 'furniture', + thumbnail: '/table.png', + dimensions: [4, 2, 4], + }, + }) + const slab = { + id: 'slab_support_batch', + type: 'slab', + parentId: LEVEL_ID, + polygon: [ + [-3, -3], + [3, -3], + [3, 3], + [-3, 3], + ], + elevation: 0.25, + thickness: 0.25, + holes: [], + visible: true, + } as unknown as SlabNode + const itemMesh = new Mesh(new BoxGeometry(4, 2, 4), new MeshBasicMaterial()) + itemMesh.position.y = 1.25 + itemMesh.updateMatrixWorld(true) + const slabMesh = new Mesh(new BoxGeometry(6, 0.25, 6), new MeshBasicMaterial()) + slabMesh.position.y = 0.125 + slabMesh.updateMatrixWorld(true) + try { + registerNode({ + kind: 'item', + schema: ItemNode, + schemaVersion: 1, + category: 'furnish', + defaults: () => ({}), + capabilities: { surfaces: { top: { height: 2 } } }, + } as unknown as AnyNodeDefinition) + useScene.setState((state) => ({ + nodes: { ...state.nodes, [item.id]: item, [slab.id]: slab }, + })) + sceneRegistry.nodes.set(item.id, itemMesh) + sceneRegistry.byType.item!.add(item.id) + sceneRegistry.nodes.set(slab.id, slabMesh) + sceneRegistry.byType.slab!.add(slab.id) + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) + useViewer.setState({ hoveredId: null }) + const camera = new PerspectiveCamera() + camera.position.set(0, 5, 0) + camera.updateMatrixWorld(true) + for (const batched of [false, true, false]) { + for (const mesh of [itemMesh, slabMesh]) { + if (batched) hideFromScene(mesh, 'batched') + else showInScene(mesh, 'batched') + } + const top = resolvePointerSupportSurface(camera, [0, 0, 0], { + includeNodeTopSurfaces: true, + }) + expect(top?.sourceNodeId).toBe(item.id) + expect(top?.worldPoint).toEqual([0, 2.25, 0]) + const floor = resolvePointerSupportSurface(camera, [0, 0, 0]) + expect(floor?.supportSlabId).toBe(slab.id) + expect(floor?.worldPoint).toEqual([0, 0.25, 0]) + } + } finally { + restoreRegistry() + for (const mesh of [itemMesh, slabMesh]) { + mesh.geometry.dispose() + mesh.material.dispose() + } + } + }) + test('keeps the ground result unless node-top surfaces are asked for', () => { addPluginPlatform() diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index f51a62df04..1d0f4a5eea 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -9,7 +9,7 @@ import { spatialGridManager, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer' import { type Camera, Matrix3, type Object3D, Raycaster, Vector3 } from 'three' import { resolveTerrainGroundHit } from '../../../lib/ground-surface' import { scopeNodeId } from '../../../lib/interaction/scope' @@ -22,6 +22,7 @@ const pointScratch = new Vector3() const worldRayOrigin = new Vector3() const worldRayDirection = new Vector3() const nodeTopRaycaster = new Raycaster() +setSurfaceRaycastLayers(nodeTopRaycaster.layers) const nodeTopNormal = new Vector3() const nodeTopNormalMatrix = new Matrix3() diff --git a/packages/editor/src/hooks/use-ceiling-events.test.ts b/packages/editor/src/hooks/use-ceiling-events.test.ts new file mode 100644 index 0000000000..20cc2b9576 --- /dev/null +++ b/packages/editor/src/hooks/use-ceiling-events.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from 'bun:test' +import { + type CeilingEvent, + CeilingNode, + emitter, + LevelNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer' +import { _roots, act, createRoot } from '@react-three/fiber' +import { createElement } from 'react' +import { + Mesh, + MeshBasicMaterial, + PerspectiveCamera, + PlaneGeometry, + type WebGLRenderer, +} from 'three' +import useEditor from '../store/use-editor' +import { useCeilingEvents } from './use-ceiling-events' + +test('ceiling-item placement keeps move and commit hits while an unhovered ceiling is batched', async () => { + const previousViewer = useViewer.getState() + const previousEditor = useEditor.getState() + const previousScene = useScene.getState() + const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT + actGlobal.IS_REACT_ACT_ENVIRONMENT = true + const canvas = Object.assign(new EventTarget(), { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + }) as unknown as HTMLCanvasElement + const root = createRoot(canvas) + const camera = new PerspectiveCamera(60, 1, 0.1, 100) + camera.position.set(0, 1, 5) + camera.lookAt(0, 3, 0) + camera.updateMatrixWorld() + const level = LevelNode.parse({ id: 'level_ceiling_batch' }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_placement_batch', + parentId: level.id, + polygon: [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ], + }) + const surface = new Mesh(new PlaneGeometry(10, 10).rotateX(-Math.PI / 2), new MeshBasicMaterial()) + surface.position.y = 3 + surface.updateMatrixWorld(true) + const moves: CeilingEvent[] = [] + const clicks: CeilingEvent[] = [] + const onMove = (event: CeilingEvent) => moves.push(event) + const onClick = (event: CeilingEvent) => clicks.push(event) + emitter.on('ceiling:move', onMove) + emitter.on('ceiling:click', onClick) + function Placement() { + useCeilingEvents() + return null + } + const send = () => { + for (const type of ['pointermove', 'click']) { + canvas.dispatchEvent(Object.assign(new Event(type), { clientX: 55, clientY: 50, button: 0 })) + } + } + try { + sceneRegistry.nodes.set(ceiling.id, surface) + sceneRegistry.byType.ceiling!.add(ceiling.id) + useScene.setState({ nodes: { [level.id]: level, [ceiling.id]: ceiling } }) + useViewer.setState({ + hoveredId: null, + cameraDragging: false, + selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] }, + }) + useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never }) + await root.configure({ + gl: { + domElement: canvas, + render() {}, + setSize() {}, + setPixelRatio() {}, + } as unknown as WebGLRenderer, + camera, + frameloop: 'never', + dpr: 1, + size: { width: 100, height: 100, top: 0, left: 0 }, + }) + await act(async () => { + root.render(createElement(Placement)) + }) + send() + hideFromScene(surface, 'batched') + send() + showInScene(surface, 'batched') + send() + expect(moves).toHaveLength(3) + expect(clicks).toHaveLength(3) + expect(moves[0]!.position[1]).toBeCloseTo(3) + for (const hit of [...moves, ...clicks]) { + expect(hit.node.id).toBe(ceiling.id) + expect(hit.position).toEqual(moves[0]!.position) + expect(hit.localPosition).toEqual(moves[0]!.localPosition) + } + expect(useViewer.getState().hoveredId).toBeNull() + } finally { + await act(async () => { + root.render(null) + }) + _roots.delete(canvas) + emitter.off('ceiling:move', onMove) + emitter.off('ceiling:click', onClick) + sceneRegistry.nodes.delete(ceiling.id) + sceneRegistry.byType.ceiling!.delete(ceiling.id) + surface.geometry.dispose() + surface.material.dispose() + useScene.setState(previousScene) + useEditor.setState(previousEditor) + useViewer.setState(previousViewer) + actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct + } +}) diff --git a/packages/nodes/src/shared/accessory-cursor.ts b/packages/nodes/src/shared/accessory-cursor.ts index af771425f9..11746b75d8 100644 --- a/packages/nodes/src/shared/accessory-cursor.ts +++ b/packages/nodes/src/shared/accessory-cursor.ts @@ -5,6 +5,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' +import { setSurfaceRaycastLayers } from '@pascal-app/viewer' import { Matrix3, Raycaster, Vector3 } from 'three' export function accessoryCursor( @@ -22,6 +23,7 @@ export function accessoryCursor( direction.transformDirection(frame.matrixWorld) } const raycaster = new Raycaster(origin, direction) + setSurfaceRaycastLayers(raycaster.layers) const nodes = useScene.getState().nodes let closest = Infinity let result: ReturnType | null = null diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index a0ef150a62..db92547646 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -18,6 +18,7 @@ import { createMaterial, createMaterialFromPresetRef, registerMaterialCacheCleanup, + setSurfaceRaycastLayers, useViewer, } from '@pascal-app/viewer' import { type Material, type Mesh, type Object3D, Raycaster } from 'three' @@ -271,6 +272,7 @@ export function previewSlotByUserData(args: PaintPreviewArgs): (() => void) | nu // Reused across calls — set from the pointer ray each time. const subtreeRaycaster = new Raycaster() +setSurfaceRaycastLayers(subtreeRaycaster.layers) /** * Resolve the slot for a kind whose paint hit lands on a proud opening proxy diff --git a/packages/nodes/src/shared/surface-raycast-batching.test.ts b/packages/nodes/src/shared/surface-raycast-batching.test.ts new file mode 100644 index 0000000000..fb02c46a26 --- /dev/null +++ b/packages/nodes/src/shared/surface-raycast-batching.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from 'bun:test' +import { + type AnyNode, + type GridEvent, + LevelNode, + type PaintResolveArgs, + SlabNode, + sceneRegistry, + useScene, + WallNode, +} from '@pascal-app/core' +import { hideFromScene, showInScene } from '@pascal-app/viewer' +import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Ray, Vector3 } from 'three' +import { resolveWallRole } from '../wall/paint' +import { accessoryCursor } from './accessory-cursor' +import { resolveSlotByReRaycast } from './slot-paint' + +test.each([ + 'door', + 'window', +] as const)('%s paint resolves the real slot before a batched proxy target is released', (kind) => { + const id = `${kind}_paint_batch` as const + const root = new Group() + const mesh = new Mesh(new BoxGeometry(1, 1, 0.1), new MeshBasicMaterial()) + mesh.userData.slotId = 'frame' + root.add(mesh) + root.updateMatrixWorld(true) + const args = { + node: { id, type: kind }, + hitObject: { userData: {} }, + ray: new Ray(new Vector3(0, 0, 2), new Vector3(0, 0, -1)), + } as unknown as PaintResolveArgs + sceneRegistry.nodes.set(id, root) + try { + for (const batched of [false, true, false]) { + if (batched) hideFromScene(mesh, 'batched') + else showInScene(mesh, 'batched') + expect(resolveSlotByReRaycast(args)).toBe('frame') + } + } finally { + sceneRegistry.nodes.delete(id) + mesh.geometry.dispose() + mesh.material.dispose() + } +}) + +test('wall paint resolves a batched face band before hover release', () => { + const node = WallNode.parse({ id: 'wall_paint_batch', start: [0, 0], end: [1, 0] }) + const root = new Group() + const mesh = new Mesh(new BoxGeometry(1, 1, 0.1), new MeshBasicMaterial()) + mesh.userData.slotId = 'lowerInterior' + root.add(mesh) + root.updateMatrixWorld(true) + sceneRegistry.nodes.set(node.id, root) + try { + hideFromScene(mesh, 'wall-batched') + expect( + resolveWallRole({ + node, + materialIndex: null, + normal: undefined, + localPosition: undefined, + ray: new Ray(new Vector3(0, 0, 2), new Vector3(0, 0, -1)), + }), + ).toBe('lowerInterior') + } finally { + sceneRegistry.nodes.delete(node.id) + mesh.geometry.dispose() + mesh.material.dispose() + } +}) + +test('accessory cursor preserves the batched slab hit and normal in its explicit host list', () => { + const previousScene = useScene.getState() + const level = LevelNode.parse({ id: 'level_accessory_batch' }) + const slab = SlabNode.parse({ id: 'slab_accessory_batch', parentId: level.id, polygon: [] }) + const mesh = new Mesh(new BoxGeometry(4, 0.25, 4), new MeshBasicMaterial()) + mesh.position.y = 2 + mesh.updateMatrixWorld(true) + sceneRegistry.nodes.set(slab.id, mesh) + useScene.setState({ nodes: { [level.id]: level, [slab.id]: slab } as Record }) + const event = { + position: [0, 0, 0], + localRay: { origin: [0, 5, 0], direction: [0, -1, 0] }, + surfaceHit: { hostId: slab.id }, + } as unknown as GridEvent + try { + for (const batched of [false, true, false]) { + if (batched) hideFromScene(mesh, 'batched') + else showInScene(mesh, 'batched') + expect(accessoryCursor(event, level.id)).toEqual({ + point: [0, 2.125, 0], + surface: true, + normal: [0, 1, 0], + }) + } + } finally { + sceneRegistry.nodes.delete(slab.id) + useScene.setState(previousScene) + mesh.geometry.dispose() + mesh.material.dispose() + } +}) diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index 9c41e857e2..ae9bbba9b7 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -17,6 +17,7 @@ import { type WallSurfaceSide, type WallSurfaceSlotId, } from '@pascal-app/core' +import { setSurfaceRaycastLayers } from '@pascal-app/viewer' import { type Material, type Mesh, type Object3D, type Ray, Raycaster } from 'three' import { buildSlotPreviewMaterial, @@ -45,6 +46,7 @@ const WALL_INDEX_SLOT = new Map( ]), ) const wallSlotRaycaster = new Raycaster() +setSurfaceRaycastLayers(wallSlotRaycaster.layers) function resolveSideFromMaterialIndex(materialIndex: number | null): WallSurfaceSide | null { const slotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex)