feat(paint-slots): migrate fence onto the unified slot model

Fence joins the node.slots paint model with one paintable 'surface' slot
(merged post/base/rail geometry is a single mesh). Resolution order matches
slab: textures-off joinery role -> node.slots.surface ref -> legacy inline
material/preset -> declared default (library:wood-finewood27). Tags the mesh
userData.slotId='surface' and wires capabilities.slots + paint. Stops
borrowing DEFAULT_STAIR_MATERIAL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-17 15:29:20 -04:00
co-authored by Claude Opus 4.8
parent a8f32dd47a
commit 5555ad1fe0
4 changed files with 115 additions and 8 deletions
+4
View File
@@ -3,8 +3,10 @@ import { buildFenceFloorplan } from './floorplan'
import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances'
import { fenceFloorplanMoveTarget } from './floorplan-move'
import { buildFenceGeometry } from './geometry'
import { fencePaint } from './paint'
import { fenceParametrics } from './parametrics'
import { FenceNode } from './schema'
import { fenceSlots } from './slots'
const SIDE_HANDLE_OFFSET = 0.27
const SIDE_HANDLE_MIN_OFFSET = 0.33
@@ -163,6 +165,8 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
surfaces: { sides: { faces: 'all' } },
duplicable: true,
deletable: true,
slots: () => fenceSlots(),
paint: fencePaint,
// Placed by drawing the span with the two-click tool; a saved preset
// seeds its build parameters via `toolDefaults.fence` (see `tool.tsx`
// and `createFenceOnCurrentLevel`).
+88 -8
View File
@@ -1,10 +1,18 @@
import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core'
import {
DEFAULT_STAIR_MATERIAL,
applyMaterialPresetToMaterials,
type ColorPreset,
createDefaultMaterial,
createMaterial,
createSurfaceRoleMaterial,
generateFenceGeometry,
type RenderShading,
resolveMaterialRef,
resolveSlotDefaultMaterial,
} from '@pascal-app/viewer'
import { Group, Mesh } from 'three'
import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
import type { FenceNode } from './schema'
import { FENCE_SLOT_DEFAULT } from './slots'
/**
* Stage B builder for fence. Reuses the legacy `generateFenceGeometry`
@@ -12,26 +20,98 @@ import type { FenceNode } from './schema'
* posts + base + top rail + curve spans) and wraps it in a Mesh-in-Group
* shape the generic `<GeometrySystem>` expects.
*
* Material is a single shared reference — fences look the same regardless
* of instance, so we don't clone per node. If per-fence material
* customization lands later (color picker on the panel maps to a real
* material), this becomes a per-node lookup.
* 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.
*
* 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.
*/
type FenceMaterial = Material & {
alphaMap?: Texture | null
depthWrite: boolean
opacity: number
transparent: boolean
}
const fenceMaterialCache = new Map<string, Material>()
function getFenceMaterial(
node: FenceNode,
shading: RenderShading,
textures: boolean,
colorPreset: ColorPreset,
sceneTheme: string | undefined,
sceneMaterials: GeometryContext['materials'],
): Material {
if (!textures) {
return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme)
}
const slotRef = node.slots?.surface
if (slotRef) {
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
if (resolved) return resolved
}
if (node.materialPreset || node.material) {
return getLegacyFenceMaterial(node, shading)
}
return resolveSlotDefaultMaterial(FENCE_SLOT_DEFAULT, shading, 0.8)
}
function getLegacyFenceMaterial(node: FenceNode, shading: RenderShading): Material {
const cacheKey = JSON.stringify({
shading,
material: node.material ?? null,
materialPreset: node.materialPreset ?? null,
})
const cached = fenceMaterialCache.get(cacheKey)
if (cached) return cached
const preset = getMaterialPresetByRef(node.materialPreset)
const material = preset
? createDefaultMaterial('#ffffff', 0.5, shading)
: node.material
? createMaterial(node.material, shading).clone()
: createDefaultMaterial('#ffffff', 0.9, shading)
if (preset) {
applyMaterialPresetToMaterials(material, preset)
}
const fenceMaterial = material as FenceMaterial
fenceMaterial.transparent = false
fenceMaterial.opacity = 1
fenceMaterial.alphaMap = null
fenceMaterial.side = FrontSide
fenceMaterial.depthWrite = true
fenceMaterial.needsUpdate = true
fenceMaterialCache.set(cacheKey, material)
return material
}
export function buildFenceGeometry(
node: FenceNode,
_ctx?: unknown,
ctx?: GeometryContext,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): Group {
const group = new Group()
const geometry = generateFenceGeometry(node)
const mesh = new Mesh(geometry, DEFAULT_STAIR_MATERIAL(shading))
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)
return group
}
+14
View File
@@ -0,0 +1,14 @@
import type { AnyNode, FenceNode } from '@pascal-app/core'
import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint'
export const fencePaint = createSlotPaintCapability({
resolveRole: () => 'surface',
applyPreview: previewGeometrySlot,
legacyEffective: (node: AnyNode) => {
const fence = node as FenceNode
if (fence.materialPreset || fence.material) {
return { material: fence.material, materialPreset: fence.materialPreset }
}
return null
},
})
+9
View File
@@ -0,0 +1,9 @@
import type { SlotDeclaration } from '@pascal-app/core'
export type FenceSlotId = 'surface'
export const FENCE_SLOT_DEFAULT = 'library:wood-finewood27'
export function fenceSlots(): SlotDeclaration[] {
return [{ slotId: 'surface', label: 'Surface', default: FENCE_SLOT_DEFAULT }]
}