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
+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
// 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
@@ -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
@@ -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<string, RoofMaterialArray>()
@@ -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)