feat(paint-slots): round-2 feedback — fence 2 slots + UVs, roof defaults, stair body

- 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) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-17 17:21:58 -04:00
co-authored by Claude Opus 4.8
parent ad227a04a2
commit e92ee05702
15 changed files with 345 additions and 271 deletions
+2 -10
View File
@@ -2288,11 +2288,7 @@ function ColumnBody({ node }: { node: ColumnNode }) {
<ColumnSlot slotId="shaft"> <ColumnSlot slotId="shaft">
<Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} /> <Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} />
<Rings node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} /> <Rings node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
<LatheBands <LatheBands node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<Flutes node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} /> <Flutes node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
<LowerCarvedBand <LowerCarvedBand
node={node} node={node}
@@ -2304,11 +2300,7 @@ function ColumnBody({ node }: { node: ColumnNode }) {
shaftHeight={shaftLayout.shaftHeight} shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY} shaftY={shaftLayout.shaftY}
/> />
<SpiralRibs <SpiralRibs node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
</ColumnSlot> </ColumnSlot>
<ColumnSlot slotId="capital"> <ColumnSlot slotId="capital">
<Capital <Capital
+1 -1
View File
@@ -12,8 +12,8 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { buildElevatorFloorplan } from './floorplan' import { buildElevatorFloorplan } from './floorplan'
import { elevatorResizeAffordance, elevatorRotateAffordance } from './floorplan-affordances' import { elevatorResizeAffordance, elevatorRotateAffordance } from './floorplan-affordances'
import { elevatorParametrics } from './parametrics'
import { elevatorPaint } from './paint' import { elevatorPaint } from './paint'
import { elevatorParametrics } from './parametrics'
import { ElevatorNode } from './schema' import { ElevatorNode } from './schema'
import { elevatorSlots } from './slots' import { elevatorSlots } from './slots'
+6 -2
View File
@@ -1577,7 +1577,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
scale={[0.18, 0.42, 0.04]} scale={[0.18, 0.42, 0.04]}
/> />
<ElevatorMeshButton <ElevatorMeshButton
active={!isDisabledLevel && !isServiceOnlyLevel && isCurrentLevel && doorOpen > 0.5} active={
!isDisabledLevel && !isServiceOnlyLevel && isCurrentLevel && doorOpen > 0.5
}
buttonKind="landing" buttonKind="landing"
disabled={isDisabledLevel || isServiceOnlyLevel} disabled={isDisabledLevel || isServiceOnlyLevel}
elevatorId={elevatorId} elevatorId={elevatorId}
@@ -1587,7 +1589,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
radius={0.045} radius={0.045}
/> />
<BoxPrimitive <BoxPrimitive
material={isQueuedLevel ? QUEUE_STRIP_MATERIALS.queued : QUEUE_STRIP_MATERIALS.idle} material={
isQueuedLevel ? QUEUE_STRIP_MATERIALS.queued : QUEUE_STRIP_MATERIALS.idle
}
position={[0, -0.12, -0.035]} position={[0, -0.12, -0.035]}
scale={[0.095, 0.025, 0.012]} scale={[0.095, 0.025, 0.012]}
/> />
+2 -1
View File
@@ -23,7 +23,8 @@ export function elevatorSlots(node: ElevatorNode): SlotDeclaration[] {
getResolvedElevatorShaftStyle(node.shaftStyle) === 'glass' || getResolvedElevatorShaftStyle(node.shaftStyle) === 'glass' ||
getResolvedElevatorDoorPanelStyle(node.doorPanelStyle) === 'glass-frame' 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 return slots
} }
+40 -21
View File
@@ -5,30 +5,30 @@ import {
createDefaultMaterial, createDefaultMaterial,
createMaterial, createMaterial,
createSurfaceRoleMaterial, createSurfaceRoleMaterial,
generateFenceGeometry, generateFenceSlotGeometries,
type RenderShading, type RenderShading,
resolveMaterialRef, resolveMaterialRef,
resolveSlotDefaultMaterial, resolveSlotDefaultMaterial,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
import type { FenceNode } from './schema' 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` * Stage B builder for fence. Splits the geometry into two paintable slots —
* (pure function from viewer that returns a merged BufferGeometry of * `panel` (posts / base / infill) and `rail` (the cap rail) — each its own Mesh
* posts + base + top rail + curve spans) and wraps it in a Mesh-in-Group * with a `userData.slotId` so the unified slot paint resolves and previews per
* shape the generic `<GeometrySystem>` expects. * part.
* *
* Materials follow the unified slot model: the single `surface` slot resolves * Per slot the material resolves: `node.slots[slotId]` (a shared scene material
* `node.slots.surface` (a shared scene material or `library:` finish) → the * or `library:` finish) → the legacy inline `node.material` / `materialPreset`
* legacy inline `node.material` / `materialPreset` (pre-slot-model scenes) → * (pre-slot-model scenes, applied to both parts) → the declared slot default.
* the declared slot default. Textures-off collapses to themed joinery. * Textures-off collapses both parts to the themed joinery role.
* *
* Phase 6 cleanup moves the 280 lines of geometry math out of the * Phase 6 cleanup moves the geometry math out of the legacy
* legacy `viewer/src/systems/fence/fence-system.tsx` into this folder * `viewer/src/systems/fence/fence-system.tsx` into this folder once the legacy
* once the legacy system file is deleted. Until then `generateFenceGeometry` * system file is deleted. Until then `generateFenceSlotGeometries` is publicly
* is publicly re-exported from viewer. * re-exported from viewer.
*/ */
type FenceMaterial = Material & { type FenceMaterial = Material & {
alphaMap?: Texture | null alphaMap?: Texture | null
@@ -37,10 +37,16 @@ type FenceMaterial = Material & {
transparent: boolean transparent: boolean
} }
const SLOT_DEFAULTS: Record<FenceSlotId, string> = {
panel: FENCE_PANEL_SLOT_DEFAULT,
rail: FENCE_RAIL_SLOT_DEFAULT,
}
const fenceMaterialCache = new Map<string, Material>() const fenceMaterialCache = new Map<string, Material>()
function getFenceMaterial( function getFenceSlotMaterial(
node: FenceNode, node: FenceNode,
slotId: FenceSlotId,
shading: RenderShading, shading: RenderShading,
textures: boolean, textures: boolean,
colorPreset: ColorPreset, colorPreset: ColorPreset,
@@ -51,7 +57,7 @@ function getFenceMaterial(
return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme) return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme)
} }
const slotRef = node.slots?.surface const slotRef = node.slots?.[slotId]
if (slotRef) { if (slotRef) {
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading) const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
if (resolved) return resolved if (resolved) return resolved
@@ -61,7 +67,7 @@ function getFenceMaterial(
return getLegacyFenceMaterial(node, shading) 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 { function getLegacyFenceMaterial(node: FenceNode, shading: RenderShading): Material {
@@ -105,13 +111,26 @@ export function buildFenceGeometry(
sceneTheme?: string, sceneTheme?: string,
): Group { ): Group {
const group = new Group() const group = new Group()
const geometry = generateFenceGeometry(node) const geometries = generateFenceSlotGeometries(node)
const material = getFenceMaterial(node, shading, textures, colorPreset, sceneTheme, ctx?.materials)
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) const mesh = new Mesh(geometry, material)
mesh.castShadow = true mesh.castShadow = true
mesh.receiveShadow = true mesh.receiveShadow = true
mesh.userData.slotId = 'surface' mesh.userData.slotId = slotId
mesh.userData.surfaceRole = 'joinery'
group.add(mesh) group.add(mesh)
}
return group return group
} }
+7 -2
View File
@@ -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' 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({ export const fencePaint = createSlotPaintCapability({
resolveRole: () => 'surface', resolveRole: resolveFenceRole,
applyPreview: previewGeometrySlot, applyPreview: previewGeometrySlot,
legacyEffective: (node: AnyNode) => { legacyEffective: (node: AnyNode) => {
const fence = node as FenceNode const fence = node as FenceNode
+9 -3
View File
@@ -1,9 +1,15 @@
import type { SlotDeclaration } from '@pascal-app/core' 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[] { 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 },
]
} }
+7 -5
View File
@@ -2,8 +2,8 @@ import {
type ColorPreset, type ColorPreset,
createDefaultMaterial, createDefaultMaterial,
createSurfaceRoleMaterial, createSurfaceRoleMaterial,
resolveSlotDefaultMaterial,
type RenderShading, type RenderShading,
resolveSlotDefaultMaterial,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import * as THREE from 'three' import * as THREE from 'three'
@@ -22,10 +22,12 @@ export function getRoofMaterials(
const materials = textures const materials = textures
? [ ? [
createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 0: Wall/Trim // Mirrors getRoofMaterialArray's catalog defaults (wall/trim concrete,
createDefaultMaterial('#e5e5e5', 1, shading, THREE.FrontSide), // 1: Deck // soft-white deck + soffit, terracotta shingle) for the no-parent path.
createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 2: Interior resolveSlotDefaultMaterial('library:concrete-plate', shading), // 0: Wall/Trim
resolveSlotDefaultMaterial('library:roof-weatheredshingles', shading), // 3: Shingle resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 1: Deck
resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 2: Interior
resolveSlotDefaultMaterial('library:roof-terracottatiles', shading), // 3: Shingle
] ]
: [ : [
createSurfaceRoleMaterial('roof', colorPreset), createSurfaceRoleMaterial('roof', colorPreset),
+1 -1
View File
@@ -406,8 +406,8 @@ import {
stairRotateAffordance, stairRotateAffordance,
} from './floorplan-affordances' } from './floorplan-affordances'
import { stairFloorplanMoveTarget } from './floorplan-move' import { stairFloorplanMoveTarget } from './floorplan-move'
import { stairParametrics } from './parametrics'
import { stairPaint } from './paint' import { stairPaint } from './paint'
import { stairParametrics } from './parametrics'
import { StairNode } from './schema' import { StairNode } from './schema'
import { stairSlots } from './slots' import { stairSlots } from './slots'
+1 -3
View File
@@ -8,9 +8,7 @@ function isStairSlotId(value: unknown): value is StairSlotId {
} }
function resolveStairPaintRole(args: PaintResolveArgs): StairSlotId | null { function resolveStairPaintRole(args: PaintResolveArgs): StairSlotId | null {
const userData = args.hitObject?.userData as const userData = args.hitObject?.userData as { slotId?: unknown; slotIds?: unknown } | undefined
| { slotId?: unknown; slotIds?: unknown }
| undefined
if (isStairSlotId(userData?.slotId)) { if (isStairSlotId(userData?.slotId)) {
return userData.slotId return userData.slotId
+1 -3
View File
@@ -160,9 +160,7 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
userData={STAIR_BODY_SLOT_USER_DATA} userData={STAIR_BODY_SLOT_USER_DATA}
/> />
) : null} ) : null}
{isSegmentBasedStair ? null : ( {isSegmentBasedStair ? null : <CurvedStairBody bodyMaterials={bodyMaterials} stair={node} />}
<CurvedStairBody bodyMaterials={bodyMaterials} stair={node} />
)}
<StairRailings material={railingMaterial} stair={node} /> <StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? ( {isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { SlotDeclaration, StairNode } from '@pascal-app/core'
export type StairSlotId = 'treads' | 'body' | 'railing' export type StairSlotId = 'treads' | 'body' | 'railing'
export const STAIR_TREADS_SLOT_DEFAULT = 'library:wood-woodplank48' 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 const STAIR_RAILING_SLOT_DEFAULT = 'library:metal-steel'
export function stairSlots(node: StairNode): SlotDeclaration[] { export function stairSlots(node: StairNode): SlotDeclaration[] {
+5 -1
View File
@@ -97,7 +97,11 @@ export { ElevatorInteractionSystem } from './systems/elevator/elevator-interacti
// Fence system follows the wall re-export pattern — composed into the // Fence system follows the wall re-export pattern — composed into the
// registry-driven fence definition's `def.system`. Removed in Phase 6 // registry-driven fence definition's `def.system`. Removed in Phase 6
// alongside the legacy fence mount point. // 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 // Generic floor-elevation system. Lifts the rendered mesh of any kind
// whose definition declares `capabilities.floorPlaced` by the slab // whose definition declares `capabilities.floorPlaced` by the slab
// elevation under its footprint. Replaces the per-kind elevation block // elevation under its footprint. Replaces the per-kind elevation block
@@ -100,16 +100,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
if (!(position && normal)) return 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) 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) { for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index) const px = position.getX(index)
@@ -123,14 +120,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
let v = 0 let v = 0
if (ny >= nx && ny >= nz) { if (ny >= nx && ny >= nz) {
u = px - minX u = px
v = pz - minZ v = pz
} else if (nx >= nz) { } else if (nx >= nz) {
u = pz - minZ u = pz
v = py - minY v = py
} else { } else {
u = px - minX u = px
v = py - minY v = py
} }
uvs[index * 2] = u 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 } return { spacingFactor: 0.3, postFactor: 0.55, baseFactor: 1, topFactor: 0.75 }
} }
function createFenceParts(fence: FenceNode): FencePart[] { export type FenceSlotParts = {
const parts: FencePart[] = [] /** 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 length = Math.max(getWallCurveLength(fence), 0.01)
const panelDepth = Math.max(fence.thickness, 0.03) const panelDepth = Math.max(fence.thickness, 0.03)
const clearance = Math.max(fence.groundClearance, 0) 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) const endInsetT = Math.max(0.501, 1 - edgeInset / length)
if (!isFloating) { if (!isFloating) {
parts.push( panel.push(
...createFenceCurveSpanParts( ...createFenceCurveSpanParts(
fence, fence,
0, 0,
@@ -183,7 +188,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
panelDepth * 1.05, panelDepth * 1.05,
), ),
) )
parts.push( panel.push(
...createFenceCurveSpanParts( ...createFenceCurveSpanParts(
fence, fence,
0, 0,
@@ -208,14 +213,14 @@ function createFenceParts(fence: FenceNode): FencePart[] {
: verticalHeight : verticalHeight
const postY = fullHeightPost ? postHeight / 2 : verticalY const postY = fullHeightPost ? postHeight / 2 : verticalY
parts.push({ panel.push({
position: [frame.point.x, postY, frame.point.y], position: [frame.point.x, postY, frame.point.y],
rotationY: -frame.tangentAngle, rotationY: -frame.tangentAngle,
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)], scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
}) })
} }
parts.push( rail.push(
...createFenceCurveSpanParts( ...createFenceCurveSpanParts(
fence, fence,
0, 0,
@@ -227,7 +232,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
) )
if (isFloating) { if (isFloating) {
parts.push( rail.push(
...createFenceCurveSpanParts( ...createFenceCurveSpanParts(
fence, fence,
0, 0,
@@ -239,13 +244,11 @@ function createFenceParts(fence: FenceNode): FencePart[] {
) )
} }
return parts return { panel, rail }
} }
export function generateFenceGeometry(fence: FenceNode) { function mergeFenceParts(parts: FencePart[]): THREE.BufferGeometry {
const parts = createFenceParts(fence)
const geometries = parts.map(createFencePartGeometry) const geometries = parts.map(createFencePartGeometry)
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => { geometries.forEach((geometry) => {
geometry.dispose() geometry.dispose()
@@ -258,6 +261,24 @@ export function generateFenceGeometry(fence: FenceNode) {
return merged 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']) { function updateFenceGeometry(fenceId: FenceNode['id']) {
const node = useScene.getState().nodes[fenceId] const node = useScene.getState().nodes[fenceId]
if (!node || node.type !== 'fence') return if (!node || node.type !== 'fence') return
@@ -10,8 +10,20 @@ import {
createMaterialFromPresetRef, createMaterialFromPresetRef,
createSurfaceRoleMaterial, createSurfaceRoleMaterial,
type RenderShading, type RenderShading,
resolveSlotDefaultMaterial,
} from '../../lib/materials' } 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] export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
const roofMaterialArrayCache = new Map<string, RoofMaterialArray>() const roofMaterialArrayCache = new Map<string, RoofMaterialArray>()
@@ -77,30 +89,42 @@ export function getRoofMaterialArray(
roofMaterial, roofMaterial,
] ]
// Textures-off (monochrome) is the guaranteed escape hatch: themed role
// colours, no catalog finishes.
if (!textures) { if (!textures) {
roofMaterialArrayCache.set(cacheKey, roleArray) roofMaterialArrayCache.set(cacheKey, roleArray)
return 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 topMaterial = createResolvedMaterial(top.material, top.materialPreset, shading)
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset, shading) const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset, shading)
const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset, shading) const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset, shading)
if (!(topMaterial || edgeMaterial || wallMaterial)) { if (!(topMaterial || edgeMaterial || wallMaterial)) {
roofMaterialArrayCache.set(cacheKey, roleArray) roofMaterialArrayCache.set(cacheKey, defaultArray)
return roleArray 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 // 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 // surface (e.g. the edge) across the shingle and soffit slots. The legacy
// catch-all still fills every role because `getEffectiveRoofSurfaceMaterial` // catch-all still fills every role because `getEffectiveRoofSurfaceMaterial`
// returns it for top/edge/wall alike. // returns it for top/edge/wall alike.
const materialArray: RoofMaterialArray = [ const materialArray: RoofMaterialArray = [
edgeMaterial ?? roofMaterial, edgeMaterial ?? defaultArray[0],
wallMaterial ?? ceilingMaterial, wallMaterial ?? defaultArray[1],
wallMaterial ?? ceilingMaterial, wallMaterial ?? defaultArray[2],
topMaterial ?? roofMaterial, topMaterial ?? defaultArray[3],
] ]
roofMaterialArrayCache.set(cacheKey, materialArray) roofMaterialArrayCache.set(cacheKey, materialArray)