From e92ee05702064d29eeefa53c5b16a38a2670de63 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 17 Jun 2026 17:21:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(paint-slots):=20round-2=20feedback=20?= =?UTF-8?q?=E2=80=94=20fence=202=20slots=20+=20UVs,=20roof=20defaults,=20s?= =?UTF-8?q?tair=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fence: split into two paint slots — panel (posts/base/infill, default charcoal) and rail (cap, default wood-finewood27) — as separate meshes with userData.slotId. Fix applyFenceUVs to continuous world-space 1 UV unit = 1 m (drop the per-part min origin that broke tiling across parts). New generateFenceSlotGeometries. - roof: real catalog defaults for the segment surfaces via getRoofMaterialArray (the actual default path): wall/trim concrete-plate (matches walls), deck + soffit soft-white, shingle terracotta; textures-off role escape hatch kept. Align nodes getRoofMaterials no-parent fallback to match. - stair: body slot default -> preset-lightgrey. - (biome formatting normalization of the round-1 merged renderer files rides along.) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nodes/src/column/renderer.tsx | 12 +- packages/nodes/src/elevator/definition.ts | 2 +- packages/nodes/src/elevator/renderer.tsx | 368 +++++++++--------- packages/nodes/src/elevator/slots.ts | 3 +- packages/nodes/src/fence/geometry.ts | 69 ++-- packages/nodes/src/fence/paint.ts | 9 +- packages/nodes/src/fence/slots.ts | 12 +- packages/nodes/src/roof/roof-materials.ts | 12 +- packages/nodes/src/stair/definition.ts | 2 +- packages/nodes/src/stair/paint.ts | 4 +- packages/nodes/src/stair/renderer.tsx | 4 +- packages/nodes/src/stair/slots.ts | 2 +- packages/viewer/src/index.ts | 6 +- .../viewer/src/systems/fence/fence-system.tsx | 73 ++-- .../viewer/src/systems/roof/roof-materials.ts | 38 +- 15 files changed, 345 insertions(+), 271 deletions(-) diff --git a/packages/nodes/src/column/renderer.tsx b/packages/nodes/src/column/renderer.tsx index 0e80ddb4..fb070206 100644 --- a/packages/nodes/src/column/renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -2288,11 +2288,7 @@ function ColumnBody({ node }: { node: ColumnNode }) { - + - + { visible={renderNode.visible} {...handlers} > - - - - - - - - - - - - - - - - + - {entries.map((entry, index) => { - const column = index % cabButtonColumns - const row = Math.floor(index / cabButtonColumns) - const isDisabledLevel = disabledLevelIds.has(entry.id) - const x = - cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX - const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY + - return ( - - ) - })} - + + + + + + + + - - - {entrySpans.map(({ entry, levelTopY }) => { - const isCurrentLevel = activeLevelId === entry.id - const isDisabledLevel = disabledLevelIds.has(entry.id) - const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id) - const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id) - const isPendingLevel = pendingLevelId === entry.id - const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel - - return ( - - + - { + const column = index % cabButtonColumns + const row = Math.floor(index / cabButtonColumns) + const isDisabledLevel = disabledLevelIds.has(entry.id) + const x = + cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX + const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY + + return ( + + ) + })} + - - - - 0.5} - buttonKind="landing" - disabled={isDisabledLevel || isServiceOnlyLevel} - elevatorId={elevatorId} - levelId={entry.id as AnyNodeId} - position={[0, 0.06, -0.045]} - queued={isQueuedLevel} - radius={0.045} - /> - - - ) - })} + + + {entrySpans.map(({ entry, levelTopY }) => { + const isCurrentLevel = activeLevelId === entry.id + const isDisabledLevel = disabledLevelIds.has(entry.id) + const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id) + const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id) + const isPendingLevel = pendingLevelId === entry.id + const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel + + return ( + + + + + + + 0.5 + } + buttonKind="landing" + disabled={isDisabledLevel || isServiceOnlyLevel} + elevatorId={elevatorId} + levelId={entry.id as AnyNodeId} + position={[0, 0.06, -0.045]} + queued={isQueuedLevel} + radius={0.045} + /> + + + + ) + })} ) diff --git a/packages/nodes/src/elevator/slots.ts b/packages/nodes/src/elevator/slots.ts index 0bc88ce3..7f1cc048 100644 --- a/packages/nodes/src/elevator/slots.ts +++ b/packages/nodes/src/elevator/slots.ts @@ -23,7 +23,8 @@ export function elevatorSlots(node: ElevatorNode): SlotDeclaration[] { getResolvedElevatorShaftStyle(node.shaftStyle) === 'glass' || getResolvedElevatorDoorPanelStyle(node.doorPanelStyle) === 'glass-frame' - if (hasGlass) slots.push({ slotId: 'glass', label: 'Glass', default: ELEVATOR_GLASS_SLOT_DEFAULT }) + if (hasGlass) + slots.push({ slotId: 'glass', label: 'Glass', default: ELEVATOR_GLASS_SLOT_DEFAULT }) return slots } diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts index f4f3f85b..dc1f52aa 100644 --- a/packages/nodes/src/fence/geometry.ts +++ b/packages/nodes/src/fence/geometry.ts @@ -5,30 +5,30 @@ import { createDefaultMaterial, createMaterial, createSurfaceRoleMaterial, - generateFenceGeometry, + generateFenceSlotGeometries, type RenderShading, resolveMaterialRef, resolveSlotDefaultMaterial, } from '@pascal-app/viewer' import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' import type { FenceNode } from './schema' -import { FENCE_SLOT_DEFAULT } from './slots' +import { FENCE_PANEL_SLOT_DEFAULT, FENCE_RAIL_SLOT_DEFAULT, type FenceSlotId } from './slots' /** - * Stage B builder for fence. Reuses the legacy `generateFenceGeometry` - * (pure function from viewer that returns a merged BufferGeometry of - * posts + base + top rail + curve spans) and wraps it in a Mesh-in-Group - * shape the generic `` expects. + * Stage B builder for fence. Splits the geometry into two paintable slots — + * `panel` (posts / base / infill) and `rail` (the cap rail) — each its own Mesh + * with a `userData.slotId` so the unified slot paint resolves and previews per + * part. * - * Materials follow the unified slot model: the single `surface` slot resolves - * `node.slots.surface` (a shared scene material or `library:` finish) → the - * legacy inline `node.material` / `materialPreset` (pre-slot-model scenes) → - * the declared slot default. Textures-off collapses to themed joinery. + * Per slot the material resolves: `node.slots[slotId]` (a shared scene material + * or `library:` finish) → the legacy inline `node.material` / `materialPreset` + * (pre-slot-model scenes, applied to both parts) → the declared slot default. + * Textures-off collapses both parts to the themed joinery role. * - * Phase 6 cleanup moves the 280 lines of geometry math out of the - * legacy `viewer/src/systems/fence/fence-system.tsx` into this folder - * once the legacy system file is deleted. Until then `generateFenceGeometry` - * is publicly re-exported from viewer. + * Phase 6 cleanup moves the geometry math out of the legacy + * `viewer/src/systems/fence/fence-system.tsx` into this folder once the legacy + * system file is deleted. Until then `generateFenceSlotGeometries` is publicly + * re-exported from viewer. */ type FenceMaterial = Material & { alphaMap?: Texture | null @@ -37,10 +37,16 @@ type FenceMaterial = Material & { transparent: boolean } +const SLOT_DEFAULTS: Record = { + panel: FENCE_PANEL_SLOT_DEFAULT, + rail: FENCE_RAIL_SLOT_DEFAULT, +} + const fenceMaterialCache = new Map() -function getFenceMaterial( +function getFenceSlotMaterial( node: FenceNode, + slotId: FenceSlotId, shading: RenderShading, textures: boolean, colorPreset: ColorPreset, @@ -51,7 +57,7 @@ function getFenceMaterial( return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme) } - const slotRef = node.slots?.surface + const slotRef = node.slots?.[slotId] if (slotRef) { const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading) if (resolved) return resolved @@ -61,7 +67,7 @@ function getFenceMaterial( return getLegacyFenceMaterial(node, shading) } - return resolveSlotDefaultMaterial(FENCE_SLOT_DEFAULT, shading, 0.8) + return resolveSlotDefaultMaterial(SLOT_DEFAULTS[slotId], shading, 0.8) } function getLegacyFenceMaterial(node: FenceNode, shading: RenderShading): Material { @@ -105,13 +111,26 @@ export function buildFenceGeometry( sceneTheme?: string, ): Group { const group = new Group() - const geometry = generateFenceGeometry(node) - const material = getFenceMaterial(node, shading, textures, colorPreset, sceneTheme, ctx?.materials) - const mesh = new Mesh(geometry, material) - mesh.castShadow = true - mesh.receiveShadow = true - mesh.userData.slotId = 'surface' - mesh.userData.surfaceRole = 'joinery' - group.add(mesh) + const geometries = generateFenceSlotGeometries(node) + + for (const slotId of ['panel', 'rail'] as const) { + const geometry = geometries[slotId] + if (geometry.getAttribute('position') === undefined) continue + const material = getFenceSlotMaterial( + node, + slotId, + shading, + textures, + colorPreset, + sceneTheme, + ctx?.materials, + ) + const mesh = new Mesh(geometry, material) + mesh.castShadow = true + mesh.receiveShadow = true + mesh.userData.slotId = slotId + group.add(mesh) + } + return group } diff --git a/packages/nodes/src/fence/paint.ts b/packages/nodes/src/fence/paint.ts index 39db9247..b1cde84a 100644 --- a/packages/nodes/src/fence/paint.ts +++ b/packages/nodes/src/fence/paint.ts @@ -1,8 +1,13 @@ -import type { AnyNode, FenceNode } from '@pascal-app/core' +import type { AnyNode, FenceNode, PaintResolveArgs } from '@pascal-app/core' import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' +function resolveFenceRole(args: PaintResolveArgs): string | null { + const slotId = (args.hitObject?.userData as { slotId?: unknown } | undefined)?.slotId + return slotId === 'panel' || slotId === 'rail' ? slotId : null +} + export const fencePaint = createSlotPaintCapability({ - resolveRole: () => 'surface', + resolveRole: resolveFenceRole, applyPreview: previewGeometrySlot, legacyEffective: (node: AnyNode) => { const fence = node as FenceNode diff --git a/packages/nodes/src/fence/slots.ts b/packages/nodes/src/fence/slots.ts index 3378ba8b..aa9b3bd7 100644 --- a/packages/nodes/src/fence/slots.ts +++ b/packages/nodes/src/fence/slots.ts @@ -1,9 +1,15 @@ import type { SlotDeclaration } from '@pascal-app/core' -export type FenceSlotId = 'surface' +export type FenceSlotId = 'panel' | 'rail' -export const FENCE_SLOT_DEFAULT = 'library:wood-finewood27' +// Body (posts / base / infill) reads as a dark composite by default; the rail +// cap as wood. Both are repaintable per slot. +export const FENCE_PANEL_SLOT_DEFAULT = 'library:preset-charcoal' +export const FENCE_RAIL_SLOT_DEFAULT = 'library:wood-finewood27' export function fenceSlots(): SlotDeclaration[] { - return [{ slotId: 'surface', label: 'Surface', default: FENCE_SLOT_DEFAULT }] + return [ + { slotId: 'panel', label: 'Panel', default: FENCE_PANEL_SLOT_DEFAULT }, + { slotId: 'rail', label: 'Rail', default: FENCE_RAIL_SLOT_DEFAULT }, + ] } diff --git a/packages/nodes/src/roof/roof-materials.ts b/packages/nodes/src/roof/roof-materials.ts index 665d155d..daf414f3 100644 --- a/packages/nodes/src/roof/roof-materials.ts +++ b/packages/nodes/src/roof/roof-materials.ts @@ -2,8 +2,8 @@ import { type ColorPreset, createDefaultMaterial, createSurfaceRoleMaterial, - resolveSlotDefaultMaterial, type RenderShading, + resolveSlotDefaultMaterial, } from '@pascal-app/viewer' import * as THREE from 'three' @@ -22,10 +22,12 @@ export function getRoofMaterials( const materials = textures ? [ - createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 0: Wall/Trim - createDefaultMaterial('#e5e5e5', 1, shading, THREE.FrontSide), // 1: Deck - createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 2: Interior - resolveSlotDefaultMaterial('library:roof-weatheredshingles', shading), // 3: Shingle + // Mirrors getRoofMaterialArray's catalog defaults (wall/trim concrete, + // soft-white deck + soffit, terracotta shingle) for the no-parent path. + resolveSlotDefaultMaterial('library:concrete-plate', shading), // 0: Wall/Trim + resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 1: Deck + resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 2: Interior + resolveSlotDefaultMaterial('library:roof-terracottatiles', shading), // 3: Shingle ] : [ createSurfaceRoleMaterial('roof', colorPreset), diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index 25400b25..7d79d3d0 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -406,8 +406,8 @@ import { stairRotateAffordance, } from './floorplan-affordances' import { stairFloorplanMoveTarget } from './floorplan-move' -import { stairParametrics } from './parametrics' import { stairPaint } from './paint' +import { stairParametrics } from './parametrics' import { StairNode } from './schema' import { stairSlots } from './slots' diff --git a/packages/nodes/src/stair/paint.ts b/packages/nodes/src/stair/paint.ts index e3bc0037..09b3de8b 100644 --- a/packages/nodes/src/stair/paint.ts +++ b/packages/nodes/src/stair/paint.ts @@ -8,9 +8,7 @@ function isStairSlotId(value: unknown): value is StairSlotId { } function resolveStairPaintRole(args: PaintResolveArgs): StairSlotId | null { - const userData = args.hitObject?.userData as - | { slotId?: unknown; slotIds?: unknown } - | undefined + const userData = args.hitObject?.userData as { slotId?: unknown; slotIds?: unknown } | undefined if (isStairSlotId(userData?.slotId)) { return userData.slotId diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx index 090a00c6..c038c919 100644 --- a/packages/nodes/src/stair/renderer.tsx +++ b/packages/nodes/src/stair/renderer.tsx @@ -160,9 +160,7 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { userData={STAIR_BODY_SLOT_USER_DATA} /> ) : null} - {isSegmentBasedStair ? null : ( - - )} + {isSegmentBasedStair ? null : } {isSegmentBasedStair ? ( diff --git a/packages/nodes/src/stair/slots.ts b/packages/nodes/src/stair/slots.ts index 876d26a1..fdbb5eca 100644 --- a/packages/nodes/src/stair/slots.ts +++ b/packages/nodes/src/stair/slots.ts @@ -3,7 +3,7 @@ import type { SlotDeclaration, StairNode } from '@pascal-app/core' export type StairSlotId = 'treads' | 'body' | 'railing' export const STAIR_TREADS_SLOT_DEFAULT = 'library:wood-woodplank48' -export const STAIR_BODY_SLOT_DEFAULT = 'library:wood-woodfine2' +export const STAIR_BODY_SLOT_DEFAULT = 'library:preset-lightgrey' export const STAIR_RAILING_SLOT_DEFAULT = 'library:metal-steel' export function stairSlots(node: StairNode): SlotDeclaration[] { diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 6782d31c..0802b53a 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -97,7 +97,11 @@ export { ElevatorInteractionSystem } from './systems/elevator/elevator-interacti // Fence system follows the wall re-export pattern — composed into the // registry-driven fence definition's `def.system`. Removed in Phase 6 // alongside the legacy fence mount point. -export { FenceSystem, generateFenceGeometry } from './systems/fence/fence-system' +export { + FenceSystem, + generateFenceGeometry, + generateFenceSlotGeometries, +} from './systems/fence/fence-system' // Generic floor-elevation system. Lifts the rendered mesh of any kind // whose definition declares `capabilities.floorPlaced` by the slab // elevation under its footprint. Replaces the per-kind elevation block diff --git a/packages/viewer/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx index 31807dad..7bfc3bd3 100644 --- a/packages/viewer/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -100,16 +100,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) { if (!(position && normal)) return + // World-scale triplanar UVs: 1 UV unit = 1 metre, sampled from the part's + // local-space (already translated into fence space) coordinates with NO + // per-part origin shift. A shared origin keeps a tiled finish continuous + // across posts, rails, and infill instead of restarting the tile at each + // part's own min corner (the previous behaviour, which broke the 1 m + // contract and made adjacent parts mistile). const uvs = new Float32Array(position.count * 2) - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - - for (let index = 0; index < position.count; index += 1) { - minX = Math.min(minX, position.getX(index)) - minY = Math.min(minY, position.getY(index)) - minZ = Math.min(minZ, position.getZ(index)) - } for (let index = 0; index < position.count; index += 1) { const px = position.getX(index) @@ -123,14 +120,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) { let v = 0 if (ny >= nx && ny >= nz) { - u = px - minX - v = pz - minZ + u = px + v = pz } else if (nx >= nz) { - u = pz - minZ - v = py - minY + u = pz + v = py } else { - u = px - minX - v = py - minY + u = px + v = py } uvs[index * 2] = u @@ -153,8 +150,16 @@ function getStyleDefaults(style: FenceNode['style']) { return { spacingFactor: 0.3, postFactor: 0.55, baseFactor: 1, topFactor: 0.75 } } -function createFenceParts(fence: FenceNode): FencePart[] { - const parts: FencePart[] = [] +export type FenceSlotParts = { + /** Posts, base/kickboard, and vertical infill — the fence body. */ + panel: FencePart[] + /** Top rail (and the floating style's matching bottom rail). */ + rail: FencePart[] +} + +function createFenceParts(fence: FenceNode): FenceSlotParts { + const panel: FencePart[] = [] + const rail: FencePart[] = [] const length = Math.max(getWallCurveLength(fence), 0.01) const panelDepth = Math.max(fence.thickness, 0.03) const clearance = Math.max(fence.groundClearance, 0) @@ -173,7 +178,7 @@ function createFenceParts(fence: FenceNode): FencePart[] { const endInsetT = Math.max(0.501, 1 - edgeInset / length) if (!isFloating) { - parts.push( + panel.push( ...createFenceCurveSpanParts( fence, 0, @@ -183,7 +188,7 @@ function createFenceParts(fence: FenceNode): FencePart[] { panelDepth * 1.05, ), ) - parts.push( + panel.push( ...createFenceCurveSpanParts( fence, 0, @@ -208,14 +213,14 @@ function createFenceParts(fence: FenceNode): FencePart[] { : verticalHeight const postY = fullHeightPost ? postHeight / 2 : verticalY - parts.push({ + panel.push({ position: [frame.point.x, postY, frame.point.y], rotationY: -frame.tangentAngle, scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)], }) } - parts.push( + rail.push( ...createFenceCurveSpanParts( fence, 0, @@ -227,7 +232,7 @@ function createFenceParts(fence: FenceNode): FencePart[] { ) if (isFloating) { - parts.push( + rail.push( ...createFenceCurveSpanParts( fence, 0, @@ -239,13 +244,11 @@ function createFenceParts(fence: FenceNode): FencePart[] { ) } - return parts + return { panel, rail } } -export function generateFenceGeometry(fence: FenceNode) { - const parts = createFenceParts(fence) +function mergeFenceParts(parts: FencePart[]): THREE.BufferGeometry { const geometries = parts.map(createFencePartGeometry) - const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() geometries.forEach((geometry) => { geometry.dispose() @@ -258,6 +261,24 @@ export function generateFenceGeometry(fence: FenceNode) { return merged } +/** + * Geometry split by paint slot: the body (posts / base / infill) and the rail + * cap, each a separate merged BufferGeometry so the fence renderer can give + * each its own material + `userData.slotId`. + */ +export function generateFenceSlotGeometries(fence: FenceNode): { + panel: THREE.BufferGeometry + rail: THREE.BufferGeometry +} { + const { panel, rail } = createFenceParts(fence) + return { panel: mergeFenceParts(panel), rail: mergeFenceParts(rail) } +} + +export function generateFenceGeometry(fence: FenceNode) { + const { panel, rail } = createFenceParts(fence) + return mergeFenceParts([...panel, ...rail]) +} + function updateFenceGeometry(fenceId: FenceNode['id']) { const node = useScene.getState().nodes[fenceId] if (!node || node.type !== 'fence') return diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts index e4040616..85f2a4db 100644 --- a/packages/viewer/src/systems/roof/roof-materials.ts +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -10,8 +10,20 @@ import { createMaterialFromPresetRef, createSurfaceRoleMaterial, type RenderShading, + resolveSlotDefaultMaterial, } from '../../lib/materials' +// Declared catalog defaults for an unpainted roof, per the 4-slot layout +// (0 wall/trim · 1 deck · 2 interior soffit · 3 shingle top). The wall/trim +// band mirrors the wall kind's default (WALL_SLOT_DEFAULT = concrete-plate) so +// a roof reads as continuous with the walls below it. +const ROOF_DEFAULT_REFS: [string, string, string, string] = [ + 'library:concrete-plate', + 'library:preset-softwhite', + 'library:preset-softwhite', + 'library:roof-terracottatiles', +] + export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material] const roofMaterialArrayCache = new Map() @@ -77,30 +89,42 @@ export function getRoofMaterialArray( roofMaterial, ] + // Textures-off (monochrome) is the guaranteed escape hatch: themed role + // colours, no catalog finishes. if (!textures) { roofMaterialArrayCache.set(cacheKey, roleArray) return roleArray } + // Textures-on default appearance: catalog finishes per slot (terracotta + // shingle, soft-white deck/soffit, wall-coloured trim). Used both when the + // roof is unpainted and to fill any individual unpainted slot below. + const defaultArray: RoofMaterialArray = [ + resolveSlotDefaultMaterial(ROOF_DEFAULT_REFS[0], shading), + resolveSlotDefaultMaterial(ROOF_DEFAULT_REFS[1], shading), + resolveSlotDefaultMaterial(ROOF_DEFAULT_REFS[2], shading), + resolveSlotDefaultMaterial(ROOF_DEFAULT_REFS[3], shading), + ] + const topMaterial = createResolvedMaterial(top.material, top.materialPreset, shading) const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset, shading) const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset, shading) if (!(topMaterial || edgeMaterial || wallMaterial)) { - roofMaterialArrayCache.set(cacheKey, roleArray) - return roleArray + roofMaterialArrayCache.set(cacheKey, defaultArray) + return defaultArray } - // Each slot resolves to its own role only, then the themed default — never + // Each slot resolves to its own role only, then the declared default — never // another role. Cross-role fallback here used to splatter a single painted // surface (e.g. the edge) across the shingle and soffit slots. The legacy // catch-all still fills every role because `getEffectiveRoofSurfaceMaterial` // returns it for top/edge/wall alike. const materialArray: RoofMaterialArray = [ - edgeMaterial ?? roofMaterial, - wallMaterial ?? ceilingMaterial, - wallMaterial ?? ceilingMaterial, - topMaterial ?? roofMaterial, + edgeMaterial ?? defaultArray[0], + wallMaterial ?? defaultArray[1], + wallMaterial ?? defaultArray[2], + topMaterial ?? defaultArray[3], ] roofMaterialArrayCache.set(cacheKey, materialArray)