diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index b01da5e2..80df0eb1 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -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 = { 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`). diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts index e87f7ebf..f4f3f85b 100644 --- a/packages/nodes/src/fence/geometry.ts +++ b/packages/nodes/src/fence/geometry.ts @@ -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 `` 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() + +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 } diff --git a/packages/nodes/src/fence/paint.ts b/packages/nodes/src/fence/paint.ts new file mode 100644 index 00000000..39db9247 --- /dev/null +++ b/packages/nodes/src/fence/paint.ts @@ -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 + }, +}) diff --git a/packages/nodes/src/fence/slots.ts b/packages/nodes/src/fence/slots.ts new file mode 100644 index 00000000..3378ba8b --- /dev/null +++ b/packages/nodes/src/fence/slots.ts @@ -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 }] +}