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
4 changes: 4 additions & 0 deletions packages/core/src/store/actions/node-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,10 @@ const updateNodesActionImpl = (

// Batch dirty-marking into a single RAF to avoid redundant callbacks during rapid updates
for (const u of updates) {
// Visibility is applied by React before the deferred dirty callback. Mark
// it now so render systems can release collective geometry in that same
// frame, including when the host uses render-on-demand.
if (u.data.visible !== undefined) get().markDirty(u.id)
pendingUpdates.add(u.id)
}
for (const pId of parentsToUpdate) {
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/store/use-scene-dirty-tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,26 @@ describe('dirty tracking', () => {
expect(useScene.getState().nodes[TRACKED]).toBeUndefined()
expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(false)
})

test('visibility updates mark dirty before the batched RAF callback', () => {
let scheduled: ((time: number) => void) | null = null
const previousRaf = globalThis.requestAnimationFrame
const previousCancelRaf = globalThis.cancelAnimationFrame
globalThis.requestAnimationFrame = ((callback: (time: number) => void) => {
scheduled = callback
return 1
}) as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame

try {
useScene.getState().updateNode(TRACKED, { visible: false })

expect(scheduled).not.toBeNull()
expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(true)
;(scheduled as ((time: number) => void) | null)?.(0)
} finally {
globalThis.requestAnimationFrame = previousRaf
globalThis.cancelAnimationFrame = previousCancelRaf
}
})
})
10 changes: 5 additions & 5 deletions packages/nodes/src/measurement/surface-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
useScene,
} from '@pascal-app/core'
import type { MeasurementAxis, MeasurementAxisGuide, MeasurementPoint } from '@pascal-app/editor'
import { SCENE_LAYER, ZONE_LAYER } from '@pascal-app/viewer'
import { setSurfaceRaycastLayers, ZONE_LAYER } from '@pascal-app/viewer'
import {
type Camera,
type InstancedMesh,
Expand Down Expand Up @@ -698,7 +698,7 @@ function collectMeasurementAxisSurfaceIntersections(
const origin = levelObject.localToWorld(new Vector3(...anchor))
const levelRotation = levelObject.getWorldQuaternion(new Quaternion())
const inverseLevelRotation = levelRotation.clone().invert()
raycaster.layers.set(SCENE_LAYER)
setSurfaceRaycastLayers(raycaster.layers)
raycaster.near = 0
raycaster.far = maxDistance
const intersections: MeasurementAxisSurfaceIntersection[] = []
Expand Down Expand Up @@ -761,9 +761,9 @@ export function createMeasurementSurfaceQuerySession(
const verificationRaycaster = new Raycaster()
const axisRaycaster = new Raycaster()
const pointer = new Vector2()
pointerRaycaster.layers.set(SCENE_LAYER)
verificationRaycaster.layers.set(SCENE_LAYER)
axisRaycaster.layers.set(SCENE_LAYER)
setSurfaceRaycastLayers(pointerRaycaster.layers)
setSurfaceRaycastLayers(verificationRaycaster.layers)
setSurfaceRaycastLayers(axisRaycaster.layers)
if (options.includeZoneLayer) pointerRaycaster.layers.enable(ZONE_LAYER)

let context: MeasurementRaycastContext | null = null
Expand Down
6 changes: 3 additions & 3 deletions packages/nodes/src/measurement/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
useInteractionScope,
useMeasurementDraft,
} from '@pascal-app/editor'
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
Expand Down Expand Up @@ -435,7 +435,7 @@ export function collectMeasurementAxisSurfaceIntersections(
const origin = levelObject.localToWorld(new Vector3(...anchor))
const levelRotation = levelObject.getWorldQuaternion(new Quaternion())
const raycaster = new Raycaster()
raycaster.layers.set(SCENE_LAYER)
setSurfaceRaycastLayers(raycaster.layers)
raycaster.near = 0
raycaster.far = maxDistance
const intersections: MeasurementAxisSurfaceIntersection[] = []
Expand Down Expand Up @@ -1775,7 +1775,7 @@ export const MeasurementTool: FC = () => {
const surfaceQuery = useMemo(() => createMeasurementSurfaceQuerySession(scene), [scene])

useEffect(() => {
raycaster.current.layers.set(SCENE_LAYER)
setSurfaceRaycastLayers(raycaster.current.layers)
}, [])

useEffect(() => () => surfaceQuery.dispose(), [surfaceQuery])
Expand Down
5 changes: 5 additions & 0 deletions packages/nodes/src/wall/system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { WallCutout, WallSystem } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { buildWallTreatmentLevelData, useWallTreatmentLevelData } from './treatment-level-data'
import { wallTreatmentProudOffsets } from './treatments'
import { WallBatchSystem } from './wall-batch-system'

function effectiveWall(wall: WallNode): WallNode {
const override = useLiveNodeOverrides.getState().get(wall.id)
Expand Down Expand Up @@ -49,13 +50,17 @@ const WallTreatmentMiterSystem = () => {
* bulk of the wall runtime (~820 lines in viewer).
* - **`WallCutout`** — cutaway-mode hide/show logic based on camera
* direction and `frontSide` / `backSide` interior/exterior tags.
* - **`WallBatchSystem`** — once a level stops changing, sews its opaque
* walls into one mesh per material set so a floor costs a handful of
* draw calls instead of one per wall face run.
*/
const WallSystems = () => {
return (
<>
<WallTreatmentMiterSystem />
<WallSystem />
<WallCutout />
<WallBatchSystem />
</>
)
}
Expand Down
30 changes: 30 additions & 0 deletions packages/nodes/src/wall/wall-batch-suspension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, test } from 'bun:test'
import type { WallMode } from '@pascal-app/viewer'
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.
*/
describe('canBatchWalls', () => {
test('merges in the one mode that leaves wall materials alone', () => {
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('stands down in the modes that make walls see-through', () => {
expect(canBatchWalls('down', false)).toBe(false)
expect(canBatchWalls('translucent', false)).toBe(false)
})

test('stands down under isolation whatever the wall mode', () => {
const modes: WallMode[] = ['up', 'cutaway', 'down', 'translucent']
for (const mode of modes) {
expect(canBatchWalls(mode, true)).toBe(false)
}
})
})
47 changes: 47 additions & 0 deletions packages/nodes/src/wall/wall-batch-system.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { sceneRegistry, useScene } from '@pascal-app/core'
import { BufferGeometry, Float32BufferAttribute, Mesh, MeshBasicMaterial } from 'three'
import { collectWallBatchCandidates } from './wall-batch-system'

const registeredIds: string[] = []

afterEach(() => {
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]
for (const material of materials) material?.dispose()
sceneRegistry.nodes.delete(id)
}
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
})

function registerWall(id: string) {
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()])
sceneRegistry.nodes.set(id, mesh)
registeredIds.push(id)
}

describe('collectWallBatchCandidates', () => {
test('keeps tinted walls out when a stale level is re-sewn', () => {
const wallIds = Array.from({ length: 10 }, (_, index) => `wall_${index}`)
for (const id of wallIds) registerWall(id)

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'],
} as never)

const tinted = new Set(wallIds.slice(0, 8))
const candidates = [...collectWallBatchCandidates('level', tinted).values()].flat()

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