diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts
index d9b2840ec8..9523900651 100644
--- a/packages/nodes/src/index.ts
+++ b/packages/nodes/src/index.ts
@@ -246,5 +246,7 @@ export { structuralGridDefinition } from './structural-grid'
export { turbineVentDefinition } from './turbine-vent'
export { unitDefinition } from './unit'
export { wallDefinition } from './wall'
+export { getCurtainAwareWallMaterials } from './wall/curtain-wall-materials'
+export { setWallMaterialsResolver } from './wall/materials-resolver'
export { windowDefinition } from './window'
export { zoneDefinition } from './zone'
diff --git a/packages/nodes/src/wall/materials-resolver.ts b/packages/nodes/src/wall/materials-resolver.ts
new file mode 100644
index 0000000000..4f51bcf04b
--- /dev/null
+++ b/packages/nodes/src/wall/materials-resolver.ts
@@ -0,0 +1,27 @@
+import type { WallMaterialsResolver } from '@pascal-app/viewer'
+import { getCurtainAwareWallMaterials } from './curtain-wall-materials'
+
+let resolver: WallMaterialsResolver = getCurtainAwareWallMaterials
+const listeners = new Set<() => void>()
+
+/**
+ * Replaces the resolver `WallCutout` uses for every wall's material set
+ * (visible / cut-away / translucent / delete variants). Hosts wrap
+ * `getCurtainAwareWallMaterials` to restyle a variant, e.g. the cut-away look;
+ * `null` restores the default.
+ */
+export function setWallMaterialsResolver(next: WallMaterialsResolver | null): void {
+ resolver = next ?? getCurtainAwareWallMaterials
+ for (const listener of listeners) listener()
+}
+
+export function getWallMaterialsResolver(): WallMaterialsResolver {
+ return resolver
+}
+
+export function subscribeWallMaterialsResolver(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx
index 396b9190d3..96c99bef58 100644
--- a/packages/nodes/src/wall/system.tsx
+++ b/packages/nodes/src/wall/system.tsx
@@ -12,9 +12,9 @@ import {
} from '@pascal-app/core'
import { timeSpan, WallCutout, WallSystem } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
-import { useEffect } from 'react'
+import { useEffect, useSyncExternalStore } from 'react'
import { curtainWallGeometryAdapter } from './curtain-wall-adapter'
-import { getCurtainAwareWallMaterials } from './curtain-wall-materials'
+import { getWallMaterialsResolver, subscribeWallMaterialsResolver } from './materials-resolver'
import { withRailingWalls } from './railing-wall-adapter'
import {
buildWallTreatmentLevelData,
@@ -182,11 +182,16 @@ const WallTreatmentMiterSystem = () => {
const wallGeometryAdapter = withRailingWalls(curtainWallGeometryAdapter)
const WallSystems = () => {
+ const materialResolver = useSyncExternalStore(
+ subscribeWallMaterialsResolver,
+ getWallMaterialsResolver,
+ getWallMaterialsResolver,
+ )
return (
<>
-
+
>
)
diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts
index 1982e54c4f..274fa6fdb1 100644
--- a/packages/nodes/src/wall/wall-batch-system.test.ts
+++ b/packages/nodes/src/wall/wall-batch-system.test.ts
@@ -11,6 +11,7 @@ import {
Object3D,
} from 'three'
import { revealAllBatchedHolds } from '../shared/node-batch/candidates'
+import { setWallMaterialsResolver } from './materials-resolver'
import {
collectTintedWalls,
collectWallBatchCandidates,
@@ -265,6 +266,29 @@ test('merged wall batches are stripped from GLB exports', () => {
expect(batch.userData.pascalExport).toBe('strip')
})
+test('a host resolver swap re-sews a batched level from the new materials', () => {
+ const { batch, root, walls } = setupBatchedLevel()
+ const restyled = new MeshBasicMaterial()
+ try {
+ // The swap re-renders each wall's cutout with the new set; the merged mesh
+ // still holds the old one until the level is sewn again.
+ setWallMaterialsResolver(viewerExports.getMaterialsForWall)
+ for (const wall of walls) wall.material = [restyled]
+ nowMs = 200
+ runFrame()
+ nowMs = 381
+ runFrame()
+
+ const sewn = root.children.find((child) => child.name === 'wall-batch') as Mesh | undefined
+ expect(sewn).toBeDefined()
+ expect(sewn).not.toBe(batch)
+ expect([sewn!.material].flat()).toContain(restyled)
+ } finally {
+ setWallMaterialsResolver(null)
+ restyled.dispose()
+ }
+})
+
test('wall batches keep waiting for pending neighbours even after the dirty census is clean', () => {
const { root, walls } = setupBatchedLevel()
let pending = 1
diff --git a/packages/nodes/src/wall/wall-batch-system.tsx b/packages/nodes/src/wall/wall-batch-system.tsx
index 7eaf0e026c..a0ee07106c 100644
--- a/packages/nodes/src/wall/wall-batch-system.tsx
+++ b/packages/nodes/src/wall/wall-batch-system.tsx
@@ -15,12 +15,14 @@ import {
isNodeRevealing,
SCENE_LAYER,
useViewer,
+ type WallMaterialsResolver,
type WallMode,
} from '@pascal-app/viewer'
import { useFrame, useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { type Material, Matrix4, Mesh, type Object3D } from 'three'
import { isSlotPaintPreviewActive } from '../shared/slot-paint'
+import { getWallMaterialsResolver } from './materials-resolver'
import {
applyWallBatchGroups,
buildWallBatch,
@@ -49,12 +51,12 @@ type BatchRecord = {
* 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.
+ * that signal. These do not go through a node at all — viewer toggles, the
+ * scene's material library and the host's wall materials resolver — 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
@@ -63,6 +65,7 @@ type AppearanceInputs = {
sceneTheme: unknown
materials: object | null
libraryVersion: number
+ resolver: WallMaterialsResolver | null
}
const lastAppearance: AppearanceInputs = {
@@ -72,12 +75,14 @@ const lastAppearance: AppearanceInputs = {
sceneTheme: undefined,
materials: null,
libraryVersion: -1,
+ resolver: null,
}
function appearanceChanged(): boolean {
const viewer = useViewer.getState()
const materials = useScene.getState().materials as object
const libraryVersion = getLibraryMaterialsVersion()
+ const resolver = getWallMaterialsResolver()
if (
lastAppearance.shading === viewer.shading &&
@@ -85,7 +90,8 @@ function appearanceChanged(): boolean {
lastAppearance.colorPreset === viewer.colorPreset &&
lastAppearance.sceneTheme === viewer.sceneTheme &&
lastAppearance.materials === materials &&
- lastAppearance.libraryVersion === libraryVersion
+ lastAppearance.libraryVersion === libraryVersion &&
+ lastAppearance.resolver === resolver
) {
return false
}
@@ -96,6 +102,7 @@ function appearanceChanged(): boolean {
lastAppearance.sceneTheme = viewer.sceneTheme
lastAppearance.materials = materials
lastAppearance.libraryVersion = libraryVersion
+ lastAppearance.resolver = resolver
return true
}