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 { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances'
import { fenceFloorplanMoveTarget } from './floorplan-move' import { fenceFloorplanMoveTarget } from './floorplan-move'
import { buildFenceGeometry } from './geometry' import { buildFenceGeometry } from './geometry'
import { fencePaint } from './paint'
import { fenceParametrics } from './parametrics' import { fenceParametrics } from './parametrics'
import { FenceNode } from './schema' import { FenceNode } from './schema'
import { fenceSlots } from './slots'
const SIDE_HANDLE_OFFSET = 0.27 const SIDE_HANDLE_OFFSET = 0.27
const SIDE_HANDLE_MIN_OFFSET = 0.33 const SIDE_HANDLE_MIN_OFFSET = 0.33
@@ -163,6 +165,8 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
surfaces: { sides: { faces: 'all' } }, surfaces: { sides: { faces: 'all' } },
duplicable: true, duplicable: true,
deletable: true, deletable: true,
slots: () => fenceSlots(),
paint: fencePaint,
// Placed by drawing the span with the two-click tool; a saved preset // Placed by drawing the span with the two-click tool; a saved preset
// seeds its build parameters via `toolDefaults.fence` (see `tool.tsx` // seeds its build parameters via `toolDefaults.fence` (see `tool.tsx`
// and `createFenceOnCurrentLevel`). // and `createFenceOnCurrentLevel`).
+88 -8
View File
@@ -1,10 +1,18 @@
import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core'
import { import {
DEFAULT_STAIR_MATERIAL, applyMaterialPresetToMaterials,
type ColorPreset,
createDefaultMaterial,
createMaterial,
createSurfaceRoleMaterial,
generateFenceGeometry, generateFenceGeometry,
type RenderShading, type RenderShading,
resolveMaterialRef,
resolveSlotDefaultMaterial,
} from '@pascal-app/viewer' } 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 type { FenceNode } from './schema'
import { FENCE_SLOT_DEFAULT } from './slots'
/** /**
* Stage B builder for fence. Reuses the legacy `generateFenceGeometry` * 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 * posts + base + top rail + curve spans) and wraps it in a Mesh-in-Group
* shape the generic `<GeometrySystem>` expects. * shape the generic `<GeometrySystem>` expects.
* *
* Material is a single shared reference — fences look the same regardless * Materials follow the unified slot model: the single `surface` slot resolves
* of instance, so we don't clone per node. If per-fence material * `node.slots.surface` (a shared scene material or `library:` finish) → the
* customization lands later (color picker on the panel maps to a real * legacy inline `node.material` / `materialPreset` (pre-slot-model scenes) →
* material), this becomes a per-node lookup. * the declared slot default. Textures-off collapses to themed joinery.
* *
* Phase 6 cleanup moves the 280 lines of geometry math out of the * Phase 6 cleanup moves the 280 lines of geometry math out of the
* legacy `viewer/src/systems/fence/fence-system.tsx` into this folder * legacy `viewer/src/systems/fence/fence-system.tsx` into this folder
* once the legacy system file is deleted. Until then `generateFenceGeometry` * once the legacy system file is deleted. Until then `generateFenceGeometry`
* is publicly re-exported from viewer. * 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( export function buildFenceGeometry(
node: FenceNode, node: FenceNode,
_ctx?: unknown, ctx?: GeometryContext,
shading: RenderShading = 'rendered', shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): Group { ): Group {
const group = new Group() const group = new Group()
const geometry = generateFenceGeometry(node) 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.castShadow = true
mesh.receiveShadow = true mesh.receiveShadow = true
mesh.userData.slotId = 'surface'
mesh.userData.surfaceRole = 'joinery'
group.add(mesh) group.add(mesh)
return group 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 }]
}