diff --git a/packages/nodes/src/measurement/surface-query.ts b/packages/nodes/src/measurement/surface-query.ts
index a101bf2c9..0bcd58484 100644
--- a/packages/nodes/src/measurement/surface-query.ts
+++ b/packages/nodes/src/measurement/surface-query.ts
@@ -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,
@@ -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[] = []
@@ -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
diff --git a/packages/nodes/src/measurement/tool.tsx b/packages/nodes/src/measurement/tool.tsx
index 361b46d5f..ea429af04 100644
--- a/packages/nodes/src/measurement/tool.tsx
+++ b/packages/nodes/src/measurement/tool.tsx
@@ -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'
@@ -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[] = []
@@ -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])
diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx
index 7f880f340..5c25e39c5 100644
--- a/packages/nodes/src/wall/system.tsx
+++ b/packages/nodes/src/wall/system.tsx
@@ -1,7 +1,7 @@
'use client'
import { type AnyNodeId, useLiveNodeOverrides, useScene, type WallNode } from '@pascal-app/core'
-import { WallCutout, WallSystem } from '@pascal-app/viewer'
+import { WallBatchSystem, WallCutout, WallSystem } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { buildWallTreatmentLevelData, useWallTreatmentLevelData } from './treatment-level-data'
import { wallTreatmentProudOffsets } from './treatments'
@@ -49,6 +49,9 @@ 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 (
@@ -56,6 +59,7 @@ const WallSystems = () => {
+
>
)
}
diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts
index 8494f1e3a..a65afe49f 100644
--- a/packages/viewer/src/index.ts
+++ b/packages/viewer/src/index.ts
@@ -89,7 +89,14 @@ export {
isIsolationActive,
} from './lib/isolation'
export { configureKtx2Support, ensureKtx2Support } from './lib/ktx2-loader'
-export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers'
+export {
+ BATCHED_LAYER,
+ GRID_LAYER,
+ OVERLAY_LAYER,
+ SCENE_LAYER,
+ setSurfaceRaycastLayers,
+ ZONE_LAYER,
+} from './lib/layers'
export {
applyMaterialPresetToMaterials,
BLUEPRINT_PALETTE,
@@ -234,6 +241,7 @@ export {
getOpeningCutoutBottomPadding,
hasFlatOpeningCutoutBottom,
} from './systems/wall/opening-cutout-geometry'
+export { WallBatchSystem } from './systems/wall/wall-batch-system'
export { getWallHideState, WallCutout } from './systems/wall/wall-cutout'
export { getVisibleWallMaterials } from './systems/wall/wall-materials'
// Wall internals re-exported so `@pascal-app/nodes`' registry-driven wall
diff --git a/packages/viewer/src/lib/geometry-groups.test.ts b/packages/viewer/src/lib/geometry-groups.test.ts
new file mode 100644
index 000000000..141d219c9
--- /dev/null
+++ b/packages/viewer/src/lib/geometry-groups.test.ts
@@ -0,0 +1,99 @@
+// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
+// depend on @types/bun so the import type is unresolved at compile time.
+import { describe, expect, test } from 'bun:test'
+import * as THREE from 'three'
+import { setGroupsSortedByMaterial } from './geometry-groups'
+
+function triangleSoup(triangleCount: number): THREE.BufferGeometry {
+ const geometry = new THREE.BufferGeometry()
+ const positions = new Float32Array(triangleCount * 9)
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
+ positions[triangle * 9] = triangle
+ positions[triangle * 9 + 3] = triangle + 1
+ positions[triangle * 9 + 7] = 1
+ }
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
+ return geometry
+}
+
+/** Triangles as vertex-index triples, in the order the GPU would draw them. */
+function drawnTriangles(geometry: THREE.BufferGeometry): number[][] {
+ const index = geometry.getIndex()
+ const count = index ? index.count : geometry.getAttribute('position').count
+ const triangles: number[][] = []
+ for (let base = 0; base < count; base += 3) {
+ triangles.push(
+ index
+ ? [index.getX(base), index.getX(base + 1), index.getX(base + 2)]
+ : [base, base + 1, base + 2],
+ )
+ }
+ return triangles
+}
+
+describe('setGroupsSortedByMaterial', () => {
+ test('collapses interleaved materials into one group each', () => {
+ const geometry = triangleSoup(6)
+
+ setGroupsSortedByMaterial(geometry, [0, 1, 0, 2, 1, 0])
+
+ expect(geometry.groups.map((group) => group.materialIndex)).toEqual([0, 1, 2])
+ expect(geometry.groups.map((group) => group.count)).toEqual([9, 6, 3])
+ expect(geometry.groups.map((group) => group.start)).toEqual([0, 9, 15])
+ })
+
+ test('keeps every triangle exactly once, only reordered', () => {
+ const geometry = triangleSoup(6)
+
+ setGroupsSortedByMaterial(geometry, [0, 1, 0, 2, 1, 0])
+
+ const drawn = drawnTriangles(geometry)
+ expect(drawn).toHaveLength(6)
+ expect([...drawn].sort((a, b) => a[0]! - b[0]!)).toEqual([
+ [0, 1, 2],
+ [3, 4, 5],
+ [6, 7, 8],
+ [9, 10, 11],
+ [12, 13, 14],
+ [15, 16, 17],
+ ])
+ })
+
+ test('draws each group with the material its triangles were assigned', () => {
+ const geometry = triangleSoup(4)
+ const assignment = [2, 0, 2, 1]
+
+ setGroupsSortedByMaterial(geometry, assignment)
+
+ const drawn = drawnTriangles(geometry)
+ for (const group of geometry.groups) {
+ for (let offset = 0; offset < group.count; offset += 3) {
+ const sourceTriangle = drawn[(group.start + offset) / 3]![0]! / 3
+ expect(assignment[sourceTriangle]).toBe(group.materialIndex!)
+ }
+ }
+ })
+
+ test('leaves a single-material geometry unindexed', () => {
+ const geometry = triangleSoup(3)
+
+ setGroupsSortedByMaterial(geometry, [1, 1, 1])
+
+ expect(geometry.getIndex()).toBeNull()
+ expect(geometry.groups).toEqual([{ start: 0, count: 9, materialIndex: 1 }])
+ })
+
+ test('reorders an existing index buffer instead of the vertices', () => {
+ const geometry = triangleSoup(3)
+ geometry.setIndex([6, 7, 8, 0, 1, 2, 3, 4, 5])
+
+ setGroupsSortedByMaterial(geometry, [1, 0, 1])
+
+ expect(drawnTriangles(geometry)).toEqual([
+ [0, 1, 2],
+ [6, 7, 8],
+ [3, 4, 5],
+ ])
+ expect(geometry.getAttribute('position').getX(0)).toBe(0)
+ })
+})
diff --git a/packages/viewer/src/lib/geometry-groups.ts b/packages/viewer/src/lib/geometry-groups.ts
new file mode 100644
index 000000000..64862ed9e
--- /dev/null
+++ b/packages/viewer/src/lib/geometry-groups.ts
@@ -0,0 +1,63 @@
+import * as THREE from 'three'
+
+/**
+ * Rewrites a geometry's material groups so every material is drawn exactly once.
+ *
+ * A group is a contiguous slice of the index buffer, so a mesh whose triangles
+ * alternate between materials pays a draw call per *run*, not per material.
+ * Extruded walls hit this hard: `ExtrudeGeometry` emits the cap and side faces
+ * interleaved, so run-length grouping produces four groups for two materials —
+ * multiplied by a thousand walls, that is thousands of avoidable draw calls.
+ * Bucketing the triangles by material first makes each material one run.
+ *
+ * The geometry gains an index buffer if it had none. Triangle winding, vertex
+ * data and material assignment are untouched, so the rendered image is
+ * unchanged; only the order in which the GPU is asked to draw it differs.
+ */
+export function setGroupsSortedByMaterial(
+ geometry: THREE.BufferGeometry,
+ triangleMaterials: ArrayLike,
+): void {
+ geometry.clearGroups()
+
+ const position = geometry.getAttribute('position')
+ if (!position) return
+
+ const sourceIndex = geometry.getIndex()
+ const triangleCount = Math.min(
+ triangleMaterials.length,
+ sourceIndex ? Math.floor(sourceIndex.count / 3) : Math.floor(position.count / 3),
+ )
+ if (triangleCount === 0) return
+
+ const buckets = new Map()
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
+ const material = triangleMaterials[triangle] ?? 0
+ const bucket = buckets.get(material)
+ if (bucket) bucket.push(triangle)
+ else buckets.set(material, [triangle])
+ }
+
+ const singleMaterial = buckets.size === 1 ? [...buckets.keys()][0] : undefined
+ if (singleMaterial !== undefined) {
+ geometry.addGroup(0, triangleCount * 3, singleMaterial)
+ return
+ }
+
+ const reordered = new Uint32Array(triangleCount * 3)
+ let cursor = 0
+
+ for (const [material, triangles] of [...buckets].sort((left, right) => left[0] - right[0])) {
+ const groupStart = cursor
+ for (const triangle of triangles) {
+ const base = triangle * 3
+ reordered[cursor] = sourceIndex ? sourceIndex.getX(base) : base
+ reordered[cursor + 1] = sourceIndex ? sourceIndex.getX(base + 1) : base + 1
+ reordered[cursor + 2] = sourceIndex ? sourceIndex.getX(base + 2) : base + 2
+ cursor += 3
+ }
+ geometry.addGroup(groupStart, cursor - groupStart, material)
+ }
+
+ geometry.setIndex(new THREE.BufferAttribute(reordered, 1))
+}
diff --git a/packages/viewer/src/lib/isolation.test.ts b/packages/viewer/src/lib/isolation.test.ts
new file mode 100644
index 000000000..9c64c88fc
--- /dev/null
+++ b/packages/viewer/src/lib/isolation.test.ts
@@ -0,0 +1,71 @@
+// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
+// depend on @types/bun so the import type is unresolved at compile time.
+import { afterEach, describe, expect, test } from 'bun:test'
+import type { AnyNodeId } from '@pascal-app/core'
+import { sceneRegistry } from '@pascal-app/core'
+import * as THREE from 'three'
+import { applyIsolation, clearIsolation } from './isolation'
+import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers'
+import { applyShadowOnly, clearShadowOnly } from './shadow-only'
+
+function register(id: string): THREE.Object3D {
+ const obj = new THREE.Object3D()
+ obj.layers.set(SCENE_LAYER)
+ sceneRegistry.nodes.set(id, obj)
+ return obj
+}
+
+/** Isolation takes node ids; the registry only cares that the key matches. */
+function isolate(...ids: string[]): void {
+ applyIsolation(ids as ReadonlyArray)
+}
+
+describe('isolation and solo, interleaved', () => {
+ afterEach(() => {
+ clearIsolation()
+ sceneRegistry.clear()
+ })
+
+ test('leaving solo while isolated keeps the filtered scene filtered', () => {
+ const level = register('level-1')
+ const focus = register('wall-1')
+ const original = level.layers.mask
+
+ applyShadowOnly(level)
+ isolate('wall-1')
+ clearShadowOnly(level)
+
+ expect(level.layers.isEnabled(SCENE_LAYER)).toBe(false)
+ expect(level.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(false)
+ expect(focus.layers.isEnabled(SCENE_LAYER)).toBe(true)
+
+ clearIsolation()
+ expect(level.layers.mask).toBe(original)
+ })
+
+ test('leaving isolation while soloed keeps the level casting shadows', () => {
+ const level = register('level-1')
+ register('wall-1')
+ const original = level.layers.mask
+
+ isolate('wall-1')
+ applyShadowOnly(level)
+ clearIsolation()
+
+ expect(level.layers.isEnabled(SCENE_LAYER)).toBe(false)
+ expect(level.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(true)
+
+ clearShadowOnly(level)
+ expect(level.layers.mask).toBe(original)
+ })
+
+ test('solo re-applied every frame does not accumulate', () => {
+ const level = register('level-1')
+ const original = level.layers.mask
+
+ for (let frame = 0; frame < 5; frame += 1) applyShadowOnly(level)
+ clearShadowOnly(level)
+
+ expect(level.layers.mask).toBe(original)
+ })
+})
diff --git a/packages/viewer/src/lib/isolation.ts b/packages/viewer/src/lib/isolation.ts
index 3c990ac5b..409a80bc4 100644
--- a/packages/viewer/src/lib/isolation.ts
+++ b/packages/viewer/src/lib/isolation.ts
@@ -3,17 +3,10 @@
import type { AnyNodeId } from '@pascal-app/core'
import { sceneRegistry } from '@pascal-app/core'
import type { Object3D } from 'three'
-import { SCENE_LAYER } from './layers'
+import { hideFromScene, showInScene } from './scene-visibility'
-// Marker on each Object3D we modify during isolation so we can restore
-// the original `layers.mask` bitfield. Stored under a `Symbol` so it
-// can't collide with any kind's own userData fields.
-const ORIGINAL_LAYERS = Symbol('isolation:original-layers')
-
-type IsolationCarrier = Object3D & { [ORIGINAL_LAYERS]?: number }
-
-// Whether a subtree is currently isolated (some objects have SCENE_LAYER
-// disabled). Read by consumers that must not act on the partial view — e.g.
+// Whether a subtree is currently isolated (some objects are held off the
+// scene layer). Read by consumers that must not act on the partial view — e.g.
// the project-thumbnail autosave skips capturing while isolated so it never
// snapshots a single focused item as the whole project's thumbnail.
let isolationActive = false
@@ -47,21 +40,21 @@ export function collectIsolationSubtree(ids: ReadonlyArray): Set | null): void {
const keep = collectIsolationSubtree(ids as ReadonlyArray)
// Iterate registered roots. For each one outside the keep set,
- // disable `SCENE_LAYER` on it and on every descendant — *except*
- // descendants that are themselves in `keep` (a kept node nested under
- // a non-kept host: the isolated door under the hidden wall).
+ // hide it and every descendant — *except* descendants that are
+ // themselves in `keep` (a kept node nested under a non-kept host:
+ // the isolated door under the hidden wall).
for (const [, obj] of sceneRegistry.nodes) {
if (keep.has(obj)) continue
hideRecursive(obj, keep)
@@ -87,11 +80,7 @@ export function applyIsolation(ids: ReadonlyArray | null): void {
function hideRecursive(obj: Object3D, keep: Set): void {
if (keep.has(obj)) return
- const carrier = obj as IsolationCarrier
- if (carrier[ORIGINAL_LAYERS] === undefined) {
- carrier[ORIGINAL_LAYERS] = obj.layers.mask
- }
- obj.layers.disable(SCENE_LAYER)
+ hideFromScene(obj, 'isolated')
for (const child of obj.children) {
hideRecursive(child, keep)
}
@@ -99,15 +88,11 @@ function hideRecursive(obj: Object3D, keep: Set): void {
export function clearIsolation(): void {
// We don't know which objects were touched without re-walking, so
- // walk every registered root + its descendants and restore any
- // stashed original-mask. `traverse` is cheap and idempotent here.
+ // walk every registered root + its descendants and drop the isolation
+ // reason wherever it was set. `traverse` is cheap and idempotent here.
for (const [, obj] of sceneRegistry.nodes) {
obj.traverse((child) => {
- const carrier = child as IsolationCarrier
- if (carrier[ORIGINAL_LAYERS] !== undefined) {
- child.layers.mask = carrier[ORIGINAL_LAYERS]
- delete carrier[ORIGINAL_LAYERS]
- }
+ showInScene(child, 'isolated')
})
}
isolationActive = false
diff --git a/packages/viewer/src/lib/layers.ts b/packages/viewer/src/lib/layers.ts
index a0603be00..22817ecd8 100644
--- a/packages/viewer/src/lib/layers.ts
+++ b/packages/viewer/src/lib/layers.ts
@@ -1,3 +1,5 @@
+import type { Layers } from 'three'
+
/** Default Three.js layer for main scene geometry. */
export const SCENE_LAYER = 0
@@ -34,3 +36,25 @@ export const GRID_LAYER = 3
* cascade) via `applyShadowOnly` / `clearShadowOnly` in `lib/shadow-only.ts`.
*/
export const SHADOW_ONLY_LAYER = 4
+
+/**
+ * Layer for wall geometry that a level batch already draws (see
+ * `lib/wall-batch.ts`). No camera or pass enables it, so a sewn wall costs no
+ * draw call, yet the mesh keeps its place in the graph: its children (door and
+ * window cutters, treatments) still render, and the invisible `collision-mesh`
+ * that carries pointer events is untouched.
+ *
+ * Raycasters that query real surfaces must opt in via
+ * {@link setSurfaceRaycastLayers}, otherwise a sewn wall would stop answering
+ * measurement rays.
+ */
+export const BATCHED_LAYER = 5
+
+/**
+ * Aims a raycaster at every real scene surface, whether a wall still draws
+ * itself or a level batch draws it for us.
+ */
+export function setSurfaceRaycastLayers(layers: Layers): void {
+ layers.set(SCENE_LAYER)
+ layers.enable(BATCHED_LAYER)
+}
diff --git a/packages/viewer/src/lib/scene-visibility.test.ts b/packages/viewer/src/lib/scene-visibility.test.ts
new file mode 100644
index 000000000..420d899ee
--- /dev/null
+++ b/packages/viewer/src/lib/scene-visibility.test.ts
@@ -0,0 +1,118 @@
+// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
+// depend on @types/bun so the import type is unresolved at compile time.
+import { describe, expect, test } from 'bun:test'
+import * as THREE from 'three'
+import { BATCHED_LAYER, OVERLAY_LAYER, SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers'
+import { hideFromScene, showInScene } from './scene-visibility'
+
+function sceneObject(): THREE.Object3D {
+ const obj = new THREE.Object3D()
+ obj.layers.set(SCENE_LAYER)
+ return obj
+}
+
+describe('scene visibility', () => {
+ test('one reason hides and gives the exact mask back', () => {
+ const obj = sceneObject()
+ obj.layers.enable(OVERLAY_LAYER)
+ const original = obj.layers.mask
+
+ hideFromScene(obj, 'isolated')
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false)
+ expect(obj.layers.isEnabled(OVERLAY_LAYER)).toBe(true)
+
+ showInScene(obj, 'isolated')
+ expect(obj.layers.mask).toBe(original)
+ })
+
+ test('the reason still standing decides the mask, whatever the order', () => {
+ const obj = sceneObject()
+
+ hideFromScene(obj, 'shadow-only')
+ hideFromScene(obj, 'isolated')
+
+ // Leaving solo first must not hand the scene layer back while the
+ // isolation filter is still up.
+ showInScene(obj, 'shadow-only')
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false)
+ expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(false)
+
+ showInScene(obj, 'isolated')
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(true)
+ })
+
+ test('dropping isolation under solo leaves the object casting shadows', () => {
+ const obj = sceneObject()
+
+ hideFromScene(obj, 'isolated')
+ hideFromScene(obj, 'shadow-only')
+ showInScene(obj, 'isolated')
+
+ expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(true)
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false)
+ })
+
+ test('the batch outranks solo, and leaving solo does not un-sew the wall', () => {
+ const obj = sceneObject()
+
+ hideFromScene(obj, 'batched')
+ hideFromScene(obj, 'shadow-only')
+ expect(obj.layers.isEnabled(BATCHED_LAYER)).toBe(true)
+ expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(false)
+
+ showInScene(obj, 'shadow-only')
+ expect(obj.layers.isEnabled(BATCHED_LAYER)).toBe(true)
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false)
+
+ showInScene(obj, 'batched')
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(true)
+ })
+
+ test('dropping the batch under solo leaves the wall casting shadows', () => {
+ const obj = sceneObject()
+
+ hideFromScene(obj, 'shadow-only')
+ hideFromScene(obj, 'batched')
+ showInScene(obj, 'batched')
+
+ expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(true)
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false)
+ expect(obj.layers.isEnabled(BATCHED_LAYER)).toBe(false)
+ })
+
+ test('re-hiding for a reason already held changes nothing', () => {
+ const obj = sceneObject()
+
+ hideFromScene(obj, 'shadow-only')
+ const held = obj.layers.mask
+ hideFromScene(obj, 'shadow-only')
+ expect(obj.layers.mask).toBe(held)
+
+ showInScene(obj, 'shadow-only')
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(true)
+ })
+
+ test('dropping a reason that was never held is a no-op', () => {
+ const obj = sceneObject()
+ const original = obj.layers.mask
+
+ showInScene(obj, 'isolated')
+ expect(obj.layers.mask).toBe(original)
+
+ hideFromScene(obj, 'shadow-only')
+ showInScene(obj, 'isolated')
+ expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(true)
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false)
+ })
+
+ test('an object hidden while already off the scene layer stays off it', () => {
+ const obj = new THREE.Object3D()
+ obj.layers.set(OVERLAY_LAYER)
+ const original = obj.layers.mask
+
+ hideFromScene(obj, 'isolated')
+ showInScene(obj, 'isolated')
+ expect(obj.layers.mask).toBe(original)
+ expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false)
+ })
+})
diff --git a/packages/viewer/src/lib/scene-visibility.ts b/packages/viewer/src/lib/scene-visibility.ts
new file mode 100644
index 000000000..2e5a5d39b
--- /dev/null
+++ b/packages/viewer/src/lib/scene-visibility.ts
@@ -0,0 +1,68 @@
+import type { Object3D } from 'three'
+import { BATCHED_LAYER, SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers'
+
+/**
+ * Why an object is currently held off the scene layer.
+ *
+ * - `isolated` — outside the focused subtree of the viewer's isolation filter.
+ * - `shadow-only` — solo mode: out of the color passes, still casting shadows.
+ * - `batched` — a level's merged wall mesh draws this wall now.
+ */
+export type HiddenReason = 'isolated' | 'shadow-only' | 'batched'
+
+/**
+ * Single owner of `Object3D.layers` for every feature that hides an object.
+ *
+ * Isolation, solo's shadow-caster pass and wall batching all hide by clearing
+ * {@link SCENE_LAYER}, and they overlap freely — a wall can be sewn into a
+ * batch, then soloed, then isolated. While each stashed and restored the mask privately, the
+ * second to finish wrote back a mask the first had since changed. Recording
+ * *reasons* rather than masks makes the order irrelevant: the mask is
+ * recomputed from the one snapshot taken when the first reason arrived, and
+ * handed back only when the last one leaves.
+ */
+const HOLD = Symbol('pascal:scene-visibility:hold')
+
+type Hold = { original: number; reasons: Set }
+
+type Holder = Object3D & { [HOLD]?: Hold }
+
+/** Holds `obj` off the scene layer for `reason`. Idempotent per reason. */
+export function hideFromScene(obj: Object3D, reason: HiddenReason): void {
+ const holder = obj as Holder
+ const hold = holder[HOLD] ?? { original: obj.layers.mask, reasons: new Set() }
+ holder[HOLD] = hold
+ hold.reasons.add(reason)
+ applyHold(obj, hold)
+}
+
+/** Drops `reason`, restoring the mask `obj` had before the first one arrived. */
+export function showInScene(obj: Object3D, reason: HiddenReason): void {
+ const holder = obj as Holder
+ const hold = holder[HOLD]
+ if (!hold) return
+
+ hold.reasons.delete(reason)
+ if (hold.reasons.size > 0) {
+ applyHold(obj, hold)
+ return
+ }
+
+ obj.layers.mask = hold.original
+ delete holder[HOLD]
+}
+
+function applyHold(obj: Object3D, hold: Hold): void {
+ obj.layers.mask = hold.original
+ obj.layers.disable(SCENE_LAYER)
+
+ // A batched wall is both drawn and shadowed by the merged mesh, so it stays
+ // out of the shadow pass too — enabling the shadow-only bit would submit its
+ // triangles a second time, on top of the copy the batch already casts.
+ if (hold.reasons.has('batched')) {
+ obj.layers.enable(BATCHED_LAYER)
+ return
+ }
+
+ if (hold.reasons.has('shadow-only')) obj.layers.enable(SHADOW_ONLY_LAYER)
+}
diff --git a/packages/viewer/src/lib/shadow-only.ts b/packages/viewer/src/lib/shadow-only.ts
index c63c10ab8..209c985c3 100644
--- a/packages/viewer/src/lib/shadow-only.ts
+++ b/packages/viewer/src/lib/shadow-only.ts
@@ -1,5 +1,5 @@
import type { Object3D } from 'three'
-import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers'
+import { hideFromScene, showInScene } from './scene-visibility'
/**
* Shadow-caster-only hiding: removes an object (and its descendants) from the
@@ -9,34 +9,22 @@ import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers'
* Uses layer masks instead of `visible = false` for two reasons: `visible`
* cascades (and, critically, prunes the object from the shadow pass too),
* while layers are tested per-object against the rendering camera — the main
- * camera never enables {@link SHADOW_ONLY_LAYER}, but every shadow-casting
+ * camera never enables the shadow-only layer, but every shadow-casting
* light's shadow camera does (see `lights.tsx`).
*
- * The original `layers.mask` is stashed under a private Symbol so
- * {@link clearShadowOnly} restores the exact prior state. Both calls are
- * idempotent and cheap to reapply.
+ * The mask itself belongs to `lib/scene-visibility.ts`, which reconciles this
+ * with the isolation filter. Both calls are idempotent and cheap to reapply —
+ * solo re-runs `applyShadowOnly` every frame so meshes rebuilt while hidden
+ * get re-hidden.
*/
-
-const ORIGINAL_LAYERS = Symbol('pascal:shadow-only:original-layers')
-
-type ShadowOnlyCarrier = Object3D & { [ORIGINAL_LAYERS]?: number }
-
export function applyShadowOnly(root: Object3D): void {
root.traverse((obj) => {
- const carrier = obj as ShadowOnlyCarrier
- if (carrier[ORIGINAL_LAYERS] === undefined) {
- carrier[ORIGINAL_LAYERS] = obj.layers.mask
- }
- obj.layers.disable(SCENE_LAYER)
- obj.layers.enable(SHADOW_ONLY_LAYER)
+ hideFromScene(obj, 'shadow-only')
})
}
export function clearShadowOnly(root: Object3D): void {
root.traverse((obj) => {
- const carrier = obj as ShadowOnlyCarrier
- if (carrier[ORIGINAL_LAYERS] === undefined) return
- obj.layers.mask = carrier[ORIGINAL_LAYERS]
- delete carrier[ORIGINAL_LAYERS]
+ showInScene(obj, 'shadow-only')
})
}
diff --git a/packages/viewer/src/lib/wall-batch.test.ts b/packages/viewer/src/lib/wall-batch.test.ts
new file mode 100644
index 000000000..d608af717
--- /dev/null
+++ b/packages/viewer/src/lib/wall-batch.test.ts
@@ -0,0 +1,174 @@
+// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
+// depend on @types/bun so the import type is unresolved at compile time.
+import { describe, expect, test } from 'bun:test'
+import * as THREE from 'three'
+import { applyWallBatchGroups, buildWallBatch, type WallBatchSource } from './wall-batch'
+
+/** One triangle per material index, laid out the way a wall arrives: non-indexed, groups sorted. */
+function wallLike(materialIndices: number[], offsetX: number): THREE.BufferGeometry {
+ const geometry = new THREE.BufferGeometry()
+ const positions = new Float32Array(materialIndices.length * 9)
+ const normals = new Float32Array(materialIndices.length * 9)
+ const uvs = new Float32Array(materialIndices.length * 6)
+
+ for (let triangle = 0; triangle < materialIndices.length; triangle += 1) {
+ for (let vertex = 0; vertex < 3; vertex += 1) {
+ const base = triangle * 9 + vertex * 3
+ positions[base] = offsetX + triangle
+ positions[base + 1] = vertex
+ normals[base] = 1
+ }
+ geometry.addGroup(triangle * 3, 3, materialIndices[triangle] as number)
+ }
+
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
+ return geometry
+}
+
+function source(
+ nodeId: string,
+ materialIndices: number[],
+ offsetX: number,
+ moveX = 0,
+): WallBatchSource {
+ return {
+ nodeId,
+ geometry: wallLike(materialIndices, offsetX),
+ matrix: new THREE.Matrix4().makeTranslation(moveX, 0, 0),
+ }
+}
+
+function groupsOf(geometry: THREE.BufferGeometry) {
+ return geometry.groups.map((group) => [group.start, group.count, group.materialIndex])
+}
+
+describe('buildWallBatch', () => {
+ test('collapses every source into one run per material index', () => {
+ const batch = buildWallBatch([
+ source('a', [0, 1, 2], 0),
+ source('b', [0, 1, 2], 10),
+ source('c', [0, 1, 2], 20),
+ ])
+
+ expect(batch).not.toBeNull()
+ expect(batch?.runs.map((run) => run.materialIndex)).toEqual([0, 1, 2])
+ expect(groupsOf(batch?.geometry as THREE.BufferGeometry)).toEqual([
+ [0, 9, 0],
+ [9, 9, 1],
+ [18, 9, 2],
+ ])
+ })
+
+ test('bakes each source matrix into the merged positions', () => {
+ const batch = buildWallBatch([source('a', [0], 0), source('b', [0], 0, 5)])
+ const positions = batch?.geometry.getAttribute('position') as THREE.BufferAttribute
+
+ expect(positions.getX(0)).toBeCloseTo(0)
+ expect(positions.getX(3)).toBeCloseTo(5)
+ })
+
+ test('keeps only the attributes every source carries', () => {
+ const bare = source('b', [0], 0)
+ bare.geometry.deleteAttribute('uv')
+
+ const batch = buildWallBatch([source('a', [0], 0), bare])
+
+ expect(batch?.geometry.getAttribute('position')).toBeDefined()
+ expect(batch?.geometry.getAttribute('normal')).toBeDefined()
+ expect(batch?.geometry.getAttribute('uv')).toBeUndefined()
+ })
+
+ test('records a slice per source inside every run', () => {
+ const batch = buildWallBatch([source('a', [0, 1], 0), source('b', [0, 1], 10)])
+ const firstRun = batch?.runs[0]
+
+ expect(firstRun?.slices.map((slice) => slice.nodeId)).toEqual(['a', 'b'])
+ expect(firstRun?.slices.map((slice) => slice.count)).toEqual([3, 3])
+ })
+})
+
+describe('applyWallBatchGroups', () => {
+ test('cuts a hidden wall out of every run without touching the buffers', () => {
+ const batch = buildWallBatch([
+ source('a', [0, 1], 0),
+ source('b', [0, 1], 10),
+ source('c', [0, 1], 20),
+ ])
+ if (!batch) throw new Error('batch expected')
+
+ const positions = batch.geometry.getAttribute('position')
+ applyWallBatchGroups(batch, new Set(['b']))
+
+ expect(groupsOf(batch.geometry)).toEqual([
+ [0, 3, 0],
+ [6, 3, 0],
+ [9, 3, 1],
+ [15, 3, 1],
+ ])
+ expect(batch.geometry.getAttribute('position')).toBe(positions)
+ })
+
+ test('restores the full runs once nothing is hidden', () => {
+ const batch = buildWallBatch([source('a', [0], 0), source('b', [0], 10)])
+ if (!batch) throw new Error('batch expected')
+
+ applyWallBatchGroups(batch, new Set(['a']))
+ applyWallBatchGroups(batch, new Set())
+
+ expect(groupsOf(batch.geometry)).toEqual([[0, 6, 0]])
+ })
+})
+
+/**
+ * The guard for the merge itself: these numbers must not follow the wall count.
+ * Drop the batching and every wall goes back to owning its own draw range, so
+ * the run and group counts below jump from three to a thousand and this fails.
+ */
+describe('draw call budget', () => {
+ const MATERIALS = [0, 1, 2]
+
+ function floor(wallCount: number): WallBatchSource[] {
+ return Array.from({ length: wallCount }, (_, index) =>
+ source(`wall_${index}`, MATERIALS, 0, index * 4),
+ )
+ }
+
+ test('holds one draw range per material however many walls the floor has', () => {
+ for (const wallCount of [1, 10, 100, 1000]) {
+ const batch = buildWallBatch(floor(wallCount))
+ if (!batch) throw new Error('batch expected')
+
+ expect(batch.runs.length).toBe(MATERIALS.length)
+ expect(batch.geometry.groups.length).toBe(MATERIALS.length)
+ expect(batch.runs[0]?.slices.length).toBe(wallCount)
+ }
+ })
+
+ test('keeps every wall addressable inside the collapsed ranges', () => {
+ const batch = buildWallBatch(floor(1000))
+ if (!batch) throw new Error('batch expected')
+
+ for (const run of batch.runs) {
+ expect(new Set(run.slices.map((slice) => slice.nodeId)).size).toBe(1000)
+ expect(run.count).toBe(3000)
+ }
+ })
+
+ test('spends draw ranges on the holes, not on the floor', () => {
+ const batch = buildWallBatch(floor(1000))
+ if (!batch) throw new Error('batch expected')
+ const positions = batch.geometry.getAttribute('position')
+
+ applyWallBatchGroups(batch, new Set(['wall_500']))
+ expect(batch.geometry.groups.length).toBe(MATERIALS.length * 2)
+
+ applyWallBatchGroups(batch, new Set(['wall_100', 'wall_500', 'wall_900']))
+ expect(batch.geometry.groups.length).toBe(MATERIALS.length * 4)
+
+ applyWallBatchGroups(batch, new Set())
+ expect(batch.geometry.groups.length).toBe(MATERIALS.length)
+ expect(batch.geometry.getAttribute('position')).toBe(positions)
+ })
+})
diff --git a/packages/viewer/src/lib/wall-batch.ts b/packages/viewer/src/lib/wall-batch.ts
new file mode 100644
index 000000000..ad8bd9c9d
--- /dev/null
+++ b/packages/viewer/src/lib/wall-batch.ts
@@ -0,0 +1,230 @@
+import * as THREE from 'three'
+import { hideFromScene, showInScene } from './scene-visibility'
+
+/** A contiguous vertex range one wall contributes to one material run. */
+export type WallBatchSlice = { nodeId: string; start: number; count: number }
+
+/** Every triangle drawn with one material index, in wall order. */
+export type WallBatchRun = {
+ materialIndex: number
+ start: number
+ count: number
+ slices: WallBatchSlice[]
+}
+
+export type WallBatchSource = {
+ nodeId: string
+ geometry: THREE.BufferGeometry
+ /** Source-local to batch-root transform, baked into the merged vertices. */
+ matrix: THREE.Matrix4
+}
+
+export type WallBatch = {
+ geometry: THREE.BufferGeometry
+ runs: WallBatchRun[]
+}
+
+const BATCH_ATTRIBUTES = ['position', 'normal', 'uv', 'uv2'] as const
+type BatchAttribute = (typeof BATCH_ATTRIBUTES)[number]
+const ATTRIBUTE_ITEM_SIZE: Record = {
+ position: 3,
+ normal: 3,
+ uv: 2,
+ uv2: 2,
+}
+
+type PlannedGroup = { materialIndex: number; start: number; count: number }
+type PlannedSource = { source: WallBatchSource; groups: PlannedGroup[] }
+
+function planSource(source: WallBatchSource): PlannedSource | null {
+ const position = source.geometry.getAttribute('position')
+ if (!position || position.count === 0) return null
+
+ const declared =
+ source.geometry.groups.length > 0
+ ? source.geometry.groups
+ : [{ start: 0, count: position.count, materialIndex: 0 }]
+
+ const groups: PlannedGroup[] = []
+ for (const group of declared) {
+ const start = Math.max(0, group.start)
+ const count = Math.min(group.count, position.count - start)
+ if (count <= 0) continue
+ groups.push({ materialIndex: group.materialIndex ?? 0, start, count })
+ }
+
+ return groups.length > 0 ? { source, groups } : null
+}
+
+/**
+ * Concatenates wall geometries into one buffer laid out material-major,
+ * wall-minor: every triangle sharing a material index ends up in a single
+ * contiguous run, so the merged mesh costs one draw call per material
+ * instead of one per wall per material.
+ *
+ * Vertices are baked into the batch root's frame, so the merged mesh needs
+ * no transform of its own. Each wall's slice of every run is recorded, which
+ * is what lets a single wall be pulled back out later without touching the
+ * buffers — see `applyWallBatchGroups`.
+ *
+ * Sources must be non-indexed (the wall pipeline's `applyWorldPlanarWallUVs`
+ * already de-indexes) and are skipped if they carry no positions.
+ */
+export function buildWallBatch(sources: readonly WallBatchSource[]): WallBatch | null {
+ const planned: PlannedSource[] = []
+ const totals = new Map()
+
+ for (const source of sources) {
+ const entry = planSource(source)
+ if (!entry) continue
+ planned.push(entry)
+ for (const group of entry.groups) {
+ totals.set(group.materialIndex, (totals.get(group.materialIndex) ?? 0) + group.count)
+ }
+ }
+
+ if (planned.length === 0) return null
+
+ const names = BATCH_ATTRIBUTES.filter((name) =>
+ planned.every((entry) => entry.source.geometry.getAttribute(name)),
+ )
+ if (!names.includes('position')) return null
+
+ let totalVertices = 0
+ for (const count of totals.values()) totalVertices += count
+
+ const buffers = new Map(
+ names.map((name) => [name, new Float32Array(totalVertices * ATTRIBUTE_ITEM_SIZE[name])]),
+ )
+
+ const normalMatrix = new THREE.Matrix3()
+ const vector = new THREE.Vector3()
+ const runs: WallBatchRun[] = []
+ let cursor = 0
+
+ for (const materialIndex of [...totals.keys()].sort((left, right) => left - right)) {
+ const runStart = cursor
+ const slices: WallBatchSlice[] = []
+
+ for (const entry of planned) {
+ const sliceStart = cursor
+ normalMatrix.getNormalMatrix(entry.source.matrix)
+
+ for (const group of entry.groups) {
+ if (group.materialIndex !== materialIndex) continue
+ copyGroup(entry.source, group, names, buffers, cursor, normalMatrix, vector)
+ cursor += group.count
+ }
+
+ if (cursor > sliceStart) {
+ slices.push({ nodeId: entry.source.nodeId, start: sliceStart, count: cursor - sliceStart })
+ }
+ }
+
+ runs.push({ materialIndex, start: runStart, count: cursor - runStart, slices })
+ }
+
+ const geometry = new THREE.BufferGeometry()
+ for (const name of names) {
+ geometry.setAttribute(
+ name,
+ new THREE.BufferAttribute(buffers.get(name)!, ATTRIBUTE_ITEM_SIZE[name]),
+ )
+ }
+ geometry.computeBoundingSphere()
+ geometry.computeBoundingBox()
+
+ const batch: WallBatch = { geometry, runs }
+ applyWallBatchGroups(batch, EMPTY_HIDDEN)
+ return batch
+}
+
+const EMPTY_HIDDEN: ReadonlySet = new Set()
+
+function copyGroup(
+ source: WallBatchSource,
+ group: PlannedGroup,
+ names: readonly BatchAttribute[],
+ buffers: Map,
+ writeAt: number,
+ normalMatrix: THREE.Matrix3,
+ vector: THREE.Vector3,
+) {
+ for (const name of names) {
+ const attribute = source.geometry.getAttribute(name)
+ const target = buffers.get(name)!
+ const itemSize = ATTRIBUTE_ITEM_SIZE[name]
+
+ for (let offset = 0; offset < group.count; offset += 1) {
+ const from = group.start + offset
+ const to = (writeAt + offset) * itemSize
+
+ if (name === 'position') {
+ vector.fromBufferAttribute(attribute, from).applyMatrix4(source.matrix)
+ target[to] = vector.x
+ target[to + 1] = vector.y
+ target[to + 2] = vector.z
+ } else if (name === 'normal') {
+ vector.fromBufferAttribute(attribute, from).applyMatrix3(normalMatrix).normalize()
+ target[to] = vector.x
+ target[to + 1] = vector.y
+ target[to + 2] = vector.z
+ } else {
+ target[to] = attribute.getX(from)
+ target[to + 1] = attribute.getY(from)
+ }
+ }
+ }
+}
+
+/**
+ * Rewrites the merged geometry's draw groups so the listed walls are skipped.
+ *
+ * Pulling a wall out of the batch is what happens while it is being dragged:
+ * it goes back to drawing itself, and the merged mesh has to stop drawing it
+ * or the two would overlap. Because each wall owns a contiguous slice of each
+ * run, skipping it is a matter of splitting that run around the hole — no
+ * vertex data moves and nothing is re-uploaded to the GPU, so a drag costs a
+ * handful of group objects rather than a rebuild of the floor.
+ *
+ * Each hidden wall adds at most one extra group (one extra draw call) per run,
+ * so callers should re-merge once the holes stop being temporary.
+ */
+export function applyWallBatchGroups(batch: WallBatch, hidden: ReadonlySet): void {
+ batch.geometry.clearGroups()
+
+ for (const run of batch.runs) {
+ let cursor = run.start
+
+ if (hidden.size > 0) {
+ for (const slice of run.slices) {
+ if (!hidden.has(slice.nodeId)) continue
+ if (slice.start > cursor) {
+ batch.geometry.addGroup(cursor, slice.start - cursor, run.materialIndex)
+ }
+ cursor = slice.start + slice.count
+ }
+ }
+
+ const end = run.start + run.count
+ if (end > cursor) batch.geometry.addGroup(cursor, end - cursor, run.materialIndex)
+ }
+}
+
+/**
+ * Silences a wall the batch now draws.
+ *
+ * Emptying the draw range is not enough — three.js still submits a zero-count
+ * group, so 1000 muted walls cost 1000 draw calls. `visible = false` would
+ * cost nothing but takes the wall's children (cutters, treatments) down with
+ * it. Moving the mesh alone off the scene layer skips it in every pass while
+ * its subtree keeps rendering and picking.
+ */
+export function hideBatchedWall(mesh: THREE.Object3D): void {
+ hideFromScene(mesh, 'batched')
+}
+
+/** Hands a wall back its own draw call — unless solo or isolation still hide it. */
+export function revealBatchedWall(mesh: THREE.Object3D): void {
+ showInScene(mesh, 'batched')
+}
diff --git a/packages/viewer/src/systems/wall/wall-batch-suspension.test.ts b/packages/viewer/src/systems/wall/wall-batch-suspension.test.ts
new file mode 100644
index 000000000..5875169f9
--- /dev/null
+++ b/packages/viewer/src/systems/wall/wall-batch-suspension.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from 'bun:test'
+import type { WallMode } from '../../store/use-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)
+ }
+ })
+})
diff --git a/packages/viewer/src/systems/wall/wall-batch-system.tsx b/packages/viewer/src/systems/wall/wall-batch-system.tsx
new file mode 100644
index 000000000..7c91a641e
--- /dev/null
+++ b/packages/viewer/src/systems/wall/wall-batch-system.tsx
@@ -0,0 +1,447 @@
+'use client'
+
+import { type AnyNodeId, sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
+import { useFrame, useThree } from '@react-three/fiber'
+import { useEffect, useRef } from 'react'
+import { type Material, Matrix4, Mesh, type Object3D } from 'three'
+import { isIsolationActive } from '../../lib/isolation'
+import { SCENE_LAYER } from '../../lib/layers'
+import {
+ applyWallBatchGroups,
+ buildWallBatch,
+ hideBatchedWall,
+ revealBatchedWall,
+ type WallBatch,
+} from '../../lib/wall-batch'
+import useViewer, { type WallMode } from '../../store/use-viewer'
+import { drainRebuiltWalls, getPendingWallRebuildCount } from './wall-system'
+
+// A level's walls are merged only once they stop changing. Below this many
+// walls a merge is not worth the buffer, and the leftovers (a selected wall,
+// a lone partition) keep drawing themselves.
+const MIN_BATCH_WALLS = 8
+// Quiet window after the last wall change before the merge runs.
+const BATCH_SETTLE_MS = 180
+
+type BatchRecord = {
+ levelId: string
+ mesh: Mesh
+ batch: WallBatch
+ hidden: Set
+ nodeIds: string[]
+}
+
+/**
+ * The inputs that re-make every wall's materials without touching a single
+ * node.
+ *
+ * A wall whose own definition changes is marked dirty and leaves its batch on
+ * that signal. These four do not go through a node at all — they are viewer
+ * toggles and the scene's material library — yet the cutaway pass rebuilds
+ * every wall's material set from them, so a merged mesh holding the old set
+ * would keep a whole floor looking the way it did before the switch. They
+ * change only when someone deliberately flips a switch, so re-sewing the
+ * scene on them is cheap.
+ */
+type AppearanceInputs = {
+ shading: unknown
+ textures: unknown
+ colorPreset: unknown
+ sceneTheme: unknown
+ materials: object | null
+}
+
+const lastAppearance: AppearanceInputs = {
+ shading: undefined,
+ textures: undefined,
+ colorPreset: undefined,
+ sceneTheme: undefined,
+ materials: null,
+}
+
+function appearanceChanged(): boolean {
+ const viewer = useViewer.getState()
+ const materials = useScene.getState().materials as object
+
+ if (
+ lastAppearance.shading === viewer.shading &&
+ lastAppearance.textures === viewer.textures &&
+ lastAppearance.colorPreset === viewer.colorPreset &&
+ lastAppearance.sceneTheme === viewer.sceneTheme &&
+ lastAppearance.materials === materials
+ ) {
+ return false
+ }
+
+ lastAppearance.shading = viewer.shading
+ lastAppearance.textures = viewer.textures
+ lastAppearance.colorPreset = viewer.colorPreset
+ lastAppearance.sceneTheme = viewer.sceneTheme
+ lastAppearance.materials = materials
+ return true
+}
+
+const batchesByLevel = new Map()
+const batchByNode = new Map()
+const staleLevels = new Set()
+const changedWalls = new Set()
+const EMPTY_IDS: ReadonlySet = new Set()
+let knownWallCount = -1
+let lastWallChangeAtMs = 0
+let batchingSuspended = false
+
+/**
+ * Batched walls are drawn by the merged mesh but still picked, measured and
+ * highlighted through their own meshes, so the merged copy must stay out of
+ * every raycast.
+ */
+function skipRaycast() {
+ // intentionally empty — see the note above
+}
+
+function showOwnGeometry(nodeId: string) {
+ const mesh = sceneRegistry.nodes.get(nodeId) as Mesh | undefined
+ if (mesh) revealBatchedWall(mesh)
+}
+
+/** Hands a wall back to itself: the merged mesh stops drawing it, it resumes. */
+function releaseWall(nodeId: string) {
+ const record = batchByNode.get(nodeId)
+ if (record) {
+ record.hidden.add(nodeId)
+ applyWallBatchGroups(record.batch, record.hidden)
+ batchByNode.delete(nodeId)
+ }
+ showOwnGeometry(nodeId)
+}
+
+function disposeLevelBatches(levelId: string) {
+ const records = batchesByLevel.get(levelId)
+ if (!records) return
+
+ for (const record of records) {
+ record.mesh.removeFromParent()
+ record.batch.geometry.dispose()
+ for (const nodeId of record.nodeIds) {
+ if (batchByNode.get(nodeId) === record) batchByNode.delete(nodeId)
+ showOwnGeometry(nodeId)
+ }
+ }
+
+ batchesByLevel.delete(levelId)
+}
+
+type Candidate = { nodeId: string; mesh: Mesh; materials: Material[] }
+
+/**
+ * A wall joins a batch only if its whole material set is opaque. Translucent
+ * and cut-away walls depend on per-object blend ordering, which merging would
+ * change — they keep the per-wall path.
+ */
+function toCandidate(nodeId: string, node: WallNode): Candidate | null {
+ if (node.visible === false) return null
+
+ const mesh = sceneRegistry.nodes.get(nodeId) as Mesh | undefined
+ if (!mesh?.visible) return null
+ // Solo's shadow-caster-only pass and the viewer's isolation filter both
+ // hide a wall by taking it off the scene layer. Sewing it in would put it
+ // back on screen through the merged mesh, which neither asked for.
+ if (!mesh.layers.isEnabled(SCENE_LAYER)) return null
+ if (!mesh.geometry?.getAttribute('position')) return null
+
+ const materials = mesh.material
+ if (!Array.isArray(materials) || materials.length === 0) return null
+ if (materials.some((material) => material.transparent)) return null
+
+ return { nodeId, mesh, materials }
+}
+
+/**
+ * Whether a level's walls may be merged at all right now.
+ *
+ * The merged mesh captures one material set when it is sewn and nothing
+ * re-reads it, so batching is only sound while every batched wall's materials
+ * hold still. That is true in one wall mode. `cutaway` re-assigns materials
+ * from the camera's facing test as the view turns, `down` makes every wall
+ * see-through and `translucent` does the same by definition — in all three the
+ * merged copy would keep drawing walls the cutaway pass has since turned to
+ * glass. Isolation is the other stand-down: it hides the level root the merged
+ * mesh hangs off, which would leave a focused batched wall drawn by nobody.
+ */
+export function canBatchWalls(wallMode: WallMode, isolationActive: boolean): boolean {
+ return !isolationActive && wallMode === 'up'
+}
+
+/**
+ * Walls the cutaway pass is currently tinting — a selection or a delete hover.
+ *
+ * It paints them by swapping the materials on the wall's own mesh, which the
+ * merged mesh does not follow, so a lit wall goes back to drawing itself. There
+ * are only ever a handful, and a handful of extra draw calls is what the tint
+ * costs.
+ */
+function collectTintedWalls(wallIds: ReadonlySet): string[] {
+ const viewer = useViewer.getState()
+ const tinted: string[] = []
+
+ for (const id of viewer.selection.selectedIds) if (wallIds.has(id)) tinted.push(id)
+ for (const id of viewer.previewSelectedIds) if (wallIds.has(id)) tinted.push(id)
+
+ const hovered = viewer.hoverHighlightMode === 'delete' ? viewer.hoveredId : null
+ if (hovered && wallIds.has(hovered)) tinted.push(hovered)
+
+ return tinted
+}
+
+function materialSetKey(materials: readonly Material[]): string {
+ return materials.map((material) => material.uuid).join('|')
+}
+
+function collectCandidates(levelId: string): Map {
+ const nodes = useScene.getState().nodes
+ const level = nodes[levelId as AnyNodeId]
+ const grouped = new Map()
+ if (level?.type !== 'level') return grouped
+
+ for (const childId of level.children) {
+ const child = nodes[childId]
+ if (child?.type !== 'wall') continue
+
+ const candidate = toCandidate(childId, child as WallNode)
+ if (!candidate) continue
+
+ const key = materialSetKey(candidate.materials)
+ const bucket = grouped.get(key)
+ if (bucket) bucket.push(candidate)
+ else grouped.set(key, [candidate])
+ }
+
+ return grouped
+}
+
+/**
+ * Walls on this level that no batch currently draws.
+ *
+ * Editing a wall drops it out of its batch — a group-list rewrite that touches
+ * no buffer — and it goes back to drawing itself. Re-sewing the level only
+ * pays off once enough walls have drifted out, so a single edit leaves the
+ * floor's merged mesh exactly where it was.
+ */
+function unbatchedWallCount(levelId: string): number {
+ const nodes = useScene.getState().nodes
+ const level = nodes[levelId as AnyNodeId]
+ if (level?.type !== 'level') return 0
+
+ let count = 0
+ for (const childId of level.children) {
+ if (batchByNode.has(childId)) continue
+ const child = nodes[childId]
+ if (child?.type !== 'wall') continue
+ if (toCandidate(childId, child as WallNode)) count++
+ }
+
+ return count
+}
+
+const localMatrix = new Matrix4()
+const rootInverse = new Matrix4()
+
+function mergeLevel(levelId: string) {
+ disposeLevelBatches(levelId)
+
+ const root = sceneRegistry.nodes.get(levelId) as Object3D | undefined
+ if (!root) return
+
+ root.updateWorldMatrix(true, false)
+ rootInverse.copy(root.matrixWorld).invert()
+
+ const records: BatchRecord[] = []
+
+ for (const candidates of collectCandidates(levelId).values()) {
+ if (candidates.length < MIN_BATCH_WALLS) continue
+
+ const sources = candidates.map((candidate) => {
+ candidate.mesh.updateWorldMatrix(true, false)
+ return {
+ nodeId: candidate.nodeId,
+ geometry: candidate.mesh.geometry,
+ matrix: localMatrix.multiplyMatrices(rootInverse, candidate.mesh.matrixWorld).clone(),
+ }
+ })
+
+ const batch = buildWallBatch(sources)
+ if (!batch) continue
+
+ const mesh = new Mesh(batch.geometry, candidates[0]!.materials)
+ mesh.name = 'wall-batch'
+ mesh.castShadow = true
+ mesh.receiveShadow = true
+ mesh.matrixAutoUpdate = false
+ mesh.raycast = skipRaycast
+ root.add(mesh)
+
+ const record: BatchRecord = {
+ levelId,
+ mesh,
+ batch,
+ hidden: new Set(),
+ nodeIds: candidates.map((candidate) => candidate.nodeId),
+ }
+ records.push(record)
+
+ for (const candidate of candidates) {
+ hideBatchedWall(candidate.mesh)
+ batchByNode.set(candidate.nodeId, record)
+ }
+ }
+
+ if (records.length > 0) batchesByLevel.set(levelId, records)
+}
+
+export const WallBatchSystem = () => {
+ const invalidate = useThree((state) => state.invalidate)
+ const wakeRef = useRef | null>(null)
+
+ useFrame(() => runBatchFrame(invalidate, wakeRef), 5)
+
+ useEffect(
+ () => () => {
+ if (wakeRef.current) clearTimeout(wakeRef.current)
+ for (const levelId of [...batchesByLevel.keys()]) disposeLevelBatches(levelId)
+ changedWalls.clear()
+ staleLevels.clear()
+ knownWallCount = -1
+ batchingSuspended = false
+ lastAppearance.shading = undefined
+ lastAppearance.textures = undefined
+ lastAppearance.colorPreset = undefined
+ lastAppearance.sceneTheme = undefined
+ lastAppearance.materials = null
+ },
+ [],
+ )
+
+ return null
+}
+
+/**
+ * Follows the scene's dirty tracking rather than watching the walls itself.
+ *
+ * A wall changes for exactly one reason the merged mesh cares about: the wall
+ * system rebuilt its geometry. That system already runs off `dirtyNodes`, so
+ * this reads the same signal from both ends — the marks still standing when
+ * this frame reaches us, and the rebuild notices the wall system left behind
+ * for the walls whose marks it has already cleared. Nothing here re-derives
+ * "did this wall move" on its own, and the per-frame cost is the size of the
+ * dirty set rather than the size of the floor.
+ */
+function runBatchFrame(
+ invalidate: () => void,
+ wakeRef: { current: ReturnType | null },
+) {
+ const wallIds = sceneRegistry.byType.wall ?? EMPTY_IDS
+ const nodes = useScene.getState().nodes
+
+ // Walls the wall system rebuilt: it clears each mark as it goes, so by the
+ // time this runs the store no longer names them.
+ drainRebuiltWalls(changedWalls)
+ // Walls still marked: the wall system deferred them to a later frame (a
+ // progressive import) or their mesh had not mounted yet.
+ for (const nodeId of useScene.getState().dirtyNodes) {
+ if (wallIds.has(nodeId)) changedWalls.add(nodeId)
+ }
+
+ let changed = changedWalls.size > 0
+
+ for (const nodeId of changedWalls) {
+ const record = batchByNode.get(nodeId)
+ if (record) staleLevels.add(record.levelId)
+ const node = nodes[nodeId as AnyNodeId]
+ if (node?.type === 'wall' && node.parentId) staleLevels.add(node.parentId)
+ releaseWall(nodeId)
+ }
+ changedWalls.clear()
+
+ // A tinted wall paints itself through materials the merged mesh never reads,
+ // so it goes back to drawing its own geometry for as long as it is lit. It
+ // stays out afterwards: one wall short of a batch is not worth re-sewing a
+ // floor over, and the level's own re-merge threshold decides when it is.
+ for (const nodeId of collectTintedWalls(wallIds)) {
+ if (!batchByNode.has(nodeId)) continue
+ const record = batchByNode.get(nodeId)
+ if (record) staleLevels.add(record.levelId)
+ releaseWall(nodeId)
+ changed = true
+ }
+
+ // A theme, texture or material-library switch re-makes every wall's
+ // materials without marking a single node, so the merged copies have to be
+ // sewn again from the new ones. See `appearanceChanged`.
+ if (appearanceChanged()) {
+ for (const levelId of [...batchesByLevel.keys()]) disposeLevelBatches(levelId)
+ for (const levelId of sceneRegistry.byType.level ?? EMPTY_IDS) staleLevels.add(levelId)
+ changed = true
+ }
+
+ // A wall that left the scene carries no mark of its own — deleting one
+ // dirties the neighbours it re-mitres, not the node that went away. The
+ // wall count moving is the cheap tell that the batch needs reconciling.
+ if (wallIds.size !== knownWallCount) {
+ knownWallCount = wallIds.size
+ for (const [nodeId, record] of [...batchByNode]) {
+ if (wallIds.has(nodeId)) continue
+ staleLevels.add(record.levelId)
+ releaseWall(nodeId)
+ changed = true
+ }
+ }
+
+ // Two things make merging unsound, and both are handled the same way: the
+ // batch stands down for as long as they hold, and sews the floors back
+ // together once they lift. Isolation hides everything outside the focused
+ // subtree, and a level's merged mesh hangs off the level root — so it goes
+ // dark with everything else, leaving a focused batched wall drawn by nobody.
+ // Every wall mode but `up` re-assigns wall materials the merged mesh does not
+ // follow. See `canBatchWalls`.
+ const suspended = !canBatchWalls(useViewer.getState().wallMode, isIsolationActive())
+ if (suspended !== batchingSuspended) {
+ batchingSuspended = suspended
+ for (const levelId of [...batchesByLevel.keys()]) disposeLevelBatches(levelId)
+ staleLevels.clear()
+ if (!suspended) {
+ for (const levelId of sceneRegistry.byType.level ?? EMPTY_IDS) staleLevels.add(levelId)
+ }
+ changed = true
+ }
+ if (batchingSuspended) {
+ staleLevels.clear()
+ return
+ }
+
+ const now = performance.now()
+ if (changed) lastWallChangeAtMs = now
+ if (staleLevels.size === 0) return
+
+ // Merging mid-edit would sew stale geometry in: a dragged wall's neighbours
+ // are deferred to the wall system's trailing-edge flush, and those rebuilds
+ // land after the drag's last dirty mark. Waiting on its queue — not just on
+ // a clock — is what keeps a re-sewn floor in step with the walls it copies.
+ const settled =
+ !changed && getPendingWallRebuildCount() === 0 && now - lastWallChangeAtMs >= BATCH_SETTLE_MS
+
+ if (!settled) {
+ // The canvas renders on demand, so nothing would bring us back once the
+ // scene goes quiet — poke one frame after the window should have closed.
+ if (wakeRef.current) clearTimeout(wakeRef.current)
+ wakeRef.current = setTimeout(() => {
+ wakeRef.current = null
+ invalidate()
+ }, BATCH_SETTLE_MS + 20)
+ return
+ }
+
+ for (const levelId of staleLevels) {
+ if (unbatchedWallCount(levelId) >= MIN_BATCH_WALLS) mergeLevel(levelId)
+ }
+ staleLevels.clear()
+}
diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx
index f84f48c0f..1ae16fb64 100644
--- a/packages/viewer/src/systems/wall/wall-system.tsx
+++ b/packages/viewer/src/systems/wall/wall-system.tsx
@@ -39,6 +39,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh'
import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils'
+import { setGroupsSortedByMaterial } from '../../lib/geometry-groups'
import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill'
import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache'
import {
@@ -333,21 +334,7 @@ function assignWallMaterialGroups(
)
}
- geometry.clearGroups()
-
- let currentMaterial = triangleMaterials[0] ?? 0
- let groupStart = 0
-
- for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) {
- const materialIndex = triangleMaterials[triangleIndex] ?? 0
- if (materialIndex === currentMaterial) continue
-
- geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
- groupStart = triangleIndex
- currentMaterial = materialIndex
- }
-
- geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial)
+ setGroupsSortedByMaterial(geometry, triangleMaterials)
}
type SplitVertex = {
@@ -496,7 +483,22 @@ const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8
let lastWallDirtyAtMs = 0
const pendingAdjacentByLevel = new Map>()
-function getPendingAdjacentCount() {
+// Walls whose geometry this system replaced since the last drain.
+//
+// The store's dirty mark is cleared the moment a wall is rebuilt, so anything
+// running later in the same frame would never see it. This is that same
+// signal, held until a consumer picks it up. Neighbours rebuilt by the
+// trailing-edge flush land here too — those never carry a dirty mark at all.
+const rebuiltWalls = new Set()
+
+/** Moves every rebuild notice collected so far into `into`. */
+export function drainRebuiltWalls(into: Set): void {
+ for (const wallId of rebuiltWalls) into.add(wallId)
+ rebuiltWalls.clear()
+}
+
+/** Rebuilds this system still owes — neighbours deferred during a drag. */
+export function getPendingWallRebuildCount(): number {
let count = 0
for (const ids of pendingAdjacentByLevel.values()) {
count += ids.size
@@ -588,6 +590,7 @@ export const WallSystem = () => {
if (mesh) {
updateWallGeometry(wallId, miterData)
clearDirty(wallId as AnyNodeId)
+ rebuiltWalls.add(wallId)
rebuiltWallIds.add(wallId)
rebuiltWallsThisFrame += 1
}
@@ -618,7 +621,7 @@ export const WallSystem = () => {
// their correct miter joins.
const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS
if (quiet && pendingAdjacentByLevel.size > 0) {
- const pendingCount = getPendingAdjacentCount()
+ const pendingCount = getPendingWallRebuildCount()
const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD
let rebuiltAdjacentThisFrame = 0
const adjacentFrameStartedAt = performance.now()
@@ -641,7 +644,10 @@ export const WallSystem = () => {
}
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
- if (mesh) updateWallGeometry(wallId, miterData)
+ if (mesh) {
+ updateWallGeometry(wallId, miterData)
+ rebuiltWalls.add(wallId)
+ }
pendingIds.delete(wallId)
rebuiltAdjacentThisFrame += 1
}