From a76155a79fc67423d56b595a6acafb356b633179 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 Oct 2026 17:31:16 +0000 Subject: [PATCH 1/2] nodes: let hosts set the wall materials resolver --- packages/nodes/src/index.ts | 2 ++ packages/nodes/src/wall/materials-resolver.ts | 27 +++++++++++++++++++ packages/nodes/src/wall/system.tsx | 11 +++++--- 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 packages/nodes/src/wall/materials-resolver.ts 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 ( <> - + ) From 6309d8a7c2f74e6a6a3f0e5f3ffb1bd106043b73 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Tue, 27 Oct 2026 20:46:33 +0000 Subject: [PATCH 2/2] nodes: re-sew wall batches when the materials resolver changes --- .../nodes/src/wall/wall-batch-system.test.ts | 24 +++++++++++++++++++ packages/nodes/src/wall/wall-batch-system.tsx | 21 ++++++++++------ 2 files changed, 38 insertions(+), 7 deletions(-) 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 }