Skip to content
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
6 changes: 5 additions & 1 deletion packages/nodes/src/wall/system.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -49,13 +49,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
10 changes: 9 additions & 1 deletion packages/viewer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions packages/viewer/src/lib/geometry-groups.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
63 changes: 63 additions & 0 deletions packages/viewer/src/lib/geometry-groups.ts
Original file line number Diff line number Diff line change
@@ -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<number>,
): 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<number, number[]>()
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))
}
71 changes: 71 additions & 0 deletions packages/viewer/src/lib/isolation.test.ts
Original file line number Diff line number Diff line change
@@ -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<AnyNodeId>)
}

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)
})
})
Loading
Loading