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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions packages/nodes/src/wall/wall-batch-suspension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import { canBatchWalls } from './wall-batch-system'

/**
* The merged mesh captures one material set when it is sewn and never re-reads
* it, so it may only exist while every batched wall's materials hold still.
* These are the states in which that is true.
* it, so it may only exist while every batched wall's materials are safe to
* represent in the merged copy. These are the states in which that is true.
*/
describe('canBatchWalls', () => {
test('merges in the one mode that leaves wall materials alone', () => {
test('merges in up mode', () => {
expect(canBatchWalls('up', false)).toBe(true)
})

test('stands down in cutaway — the facing test re-assigns materials as the camera turns', () => {
expect(canBatchWalls('cutaway', false)).toBe(false)
test('stays live in cutaway while hidden walls are released individually', () => {
expect(canBatchWalls('cutaway', false)).toBe(true)
})

test('stands down in the modes that make walls see-through', () => {
Expand Down
213 changes: 202 additions & 11 deletions packages/nodes/src/wall/wall-batch-system.test.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,116 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { afterAll, afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
import { sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BufferGeometry, Float32BufferAttribute, Mesh, MeshBasicMaterial } from 'three'
import { collectTintedWalls, collectWallBatchCandidates } from './wall-batch-system'
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
import type { useFrame } from '@react-three/fiber'
import { createElement } from 'react'
import { renderToString } from 'react-dom/server'
import {
BufferGeometry,
Float32BufferAttribute,
type Material,
Mesh,
MeshBasicMaterial,
Object3D,
} from 'three'
import {
collectTintedWalls,
collectWallBatchCandidates,
WallBatchSystem,
} from './wall-batch-system'

type FrameCallback = Parameters<typeof useFrame>[0]
let frameCallback: FrameCallback | null = null
let nowMs = 0
const fiber = await import('@react-three/fiber')

mock.module('@react-three/fiber', () => ({
...fiber,
useFrame: (callback: FrameCallback) => {
frameCallback = callback
},
useThree: (selector: (state: { invalidate: () => void }) => unknown) =>
selector({ invalidate: () => undefined }),
}))

const performanceNow = spyOn(performance, 'now').mockImplementation(() => nowMs)

const registeredIds: string[] = []

afterEach(() => {
useViewer.setState({ wallMode: 'down' } as never)
frameCallback?.({} as never, 0)
for (const id of registeredIds.splice(0)) {
const mesh = sceneRegistry.nodes.get(id) as Mesh | undefined
mesh?.geometry.dispose()
const materials = Array.isArray(mesh?.material) ? mesh.material : [mesh?.material]
const object = sceneRegistry.nodes.get(id)
if (!(object instanceof Mesh)) continue
object.geometry.dispose()
const materials = Array.isArray(object.material) ? object.material : [object.material]
for (const material of materials) material?.dispose()
sceneRegistry.nodes.delete(id)
}
sceneRegistry.clear()
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
useViewer.setState({ hoverHighlightMode: 'default', hoveredId: null } as never)
useViewer.setState({ hoverHighlightMode: 'default', hoveredId: null, wallMode: 'up' } as never)
frameCallback = null
})

function registerWall(id: string) {
afterAll(() => {
performanceNow.mockRestore()
})

function registerWall(id: string, material: Material = new MeshBasicMaterial()) {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3))
const mesh = new Mesh(geometry, [new MeshBasicMaterial()])
const mesh = new Mesh(geometry, [material])
sceneRegistry.nodes.set(id, mesh)
sceneRegistry.byType.wall.add(id)
registeredIds.push(id)
return mesh
}

function takeFrameCallback(): FrameCallback {
const callback = frameCallback
if (!callback) throw new Error('frame callback expected')
return callback
}

function setupBatchedLevel(count = 8) {
const root = new Object3D()
const material = new MeshBasicMaterial()
const wallIds = Array.from({ length: count }, (_, index) => `wall_${index}`)
const walls = wallIds.map((id) => registerWall(id, material))
for (const wall of walls) root.add(wall)
sceneRegistry.nodes.set('level', root)
sceneRegistry.byType.level.add('level')
registeredIds.push('level')

useScene.setState({
nodes: {
level: { id: 'level', type: 'level', children: wallIds },
...Object.fromEntries(
wallIds.map((id) => [id, { id, type: 'wall', parentId: 'level', visible: true }]),
),
},
rootNodeIds: ['level'],
dirtyNodes: new Set(),
} as never)
const selection = useViewer.getState().selection
useViewer.setState({
wallMode: 'cutaway',
selection: { ...selection, selectedIds: new Set() },
previewSelectedIds: new Set(),
hoveredId: null,
} as never)

frameCallback = null
renderToString(createElement(WallBatchSystem))
const runFrame = takeFrameCallback()
nowMs = 0
runFrame({} as never, 0)
nowMs = 181
runFrame({} as never, 0)

const batch = root.children.find((child) => child.name === 'wall-batch') as Mesh | undefined
if (!batch) throw new Error('wall batch expected')
return { batch, root, runFrame, walls }
}

describe('collectWallBatchCandidates', () => {
Expand All @@ -46,6 +133,27 @@ describe('collectWallBatchCandidates', () => {

expect(candidates.map((candidate) => candidate.nodeId)).toEqual(wallIds.slice(8))
})

test('keeps walls stamped hidden out of a batch', () => {
registerWall('wall_hidden')
const mesh = sceneRegistry.nodes.get('wall_hidden') as Mesh
mesh.userData.wallHidden = true

useScene.setState({
nodes: {
level: { id: 'level', type: 'level', children: ['wall_hidden'] },
wall_hidden: {
id: 'wall_hidden',
type: 'wall',
parentId: 'level',
visible: true,
},
},
rootNodeIds: ['level'],
} as never)

expect(collectWallBatchCandidates('level').size).toBe(0)
})
})

describe('collectTintedWalls', () => {
Expand All @@ -72,3 +180,86 @@ describe('collectTintedWalls', () => {
expect([...collectTintedWalls(wallIds)]).toEqual([])
})
})

describe('WallBatchSystem cutaway releases', () => {
test('releases a batched wall on the frame its wallHidden stamp appears', () => {
const { batch, runFrame, walls } = setupBatchedLevel()
const wall = walls[0]!
wall.userData.wallHidden = true

nowMs = 200
runFrame({} as never, 0)

expect(wall.layers.isEnabled(SCENE_LAYER)).toBe(true)
expect(batch.geometry.groups[0]?.count).toBe(21)
})

test('does not count hidden walls toward the settled re-merge threshold', () => {
const { batch, root, runFrame, walls } = setupBatchedLevel()
for (const wall of walls) wall.userData.wallHidden = true

nowMs = 200
runFrame({} as never, 0)
nowMs = 381
runFrame({} as never, 0)

expect(root.children.find((child) => child.name === 'wall-batch')).toBe(batch)
expect(walls.every((wall) => wall.layers.isEnabled(SCENE_LAYER))).toBe(true)
})

test('re-sews released walls that become visible before settlement', () => {
const { batch, root, runFrame, walls } = setupBatchedLevel()
for (const wall of walls) wall.userData.wallHidden = true

nowMs = 200
runFrame({} as never, 0)
for (const wall of walls) wall.userData.wallHidden = false
nowMs = 201
runFrame({} as never, 0)
nowMs = 381
runFrame({} as never, 0)

expect(root.children.find((child) => child.name === 'wall-batch')).not.toBe(batch)
expect(walls.every((wall) => !wall.layers.isEnabled(SCENE_LAYER))).toBe(true)
})

test('re-sews a settled level once its hidden walls become visible again', () => {
const { root, runFrame, walls } = setupBatchedLevel()
for (const wall of walls) wall.userData.wallHidden = true

nowMs = 200
runFrame({} as never, 0)
nowMs = 381
runFrame({} as never, 0)
expect(walls.every((wall) => wall.layers.isEnabled(SCENE_LAYER))).toBe(true)

for (const wall of walls) wall.userData.wallHidden = false
nowMs = 400
runFrame({} as never, 0)
nowMs = 581
runFrame({} as never, 0)

expect(root.children.some((child) => child.name === 'wall-batch')).toBe(true)
expect(walls.every((wall) => !wall.layers.isEnabled(SCENE_LAYER))).toBe(true)
})

test('a hovered wall does not hold the settle window open', () => {
const { root, runFrame, walls } = setupBatchedLevel(9)
for (const wall of walls) wall.userData.wallHidden = true
nowMs = 200
runFrame({} as never, 0)
for (const wall of walls) wall.userData.wallHidden = false
useViewer.setState({ hoveredId: 'wall_0' } as never)

nowMs = 400
runFrame({} as never, 0)
nowMs = 581
runFrame({} as never, 0)
nowMs = 762
runFrame({} as never, 0)

expect(root.children.some((child) => child.name === 'wall-batch')).toBe(true)
expect(walls.slice(1).every((wall) => !wall.layers.isEnabled(SCENE_LAYER))).toBe(true)
expect(walls[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true)
})
})
Loading
Loading