feat(paint-slots): migrate wall + procedural kinds onto node.slots

Retire the inline material fields on every slot-model kind, moving them
onto the unified node.slots model on load so painting, edit-propagation,
and the picker behave uniformly.

Walls: slots {interior,exterior}; slot-first viewer resolution threading
sceneMaterials (content folded into the wall material hash); WallRenderer
subscribes to the scene-material palette so a scene-material edit
re-renders live; wallPaint rebuilt on createSlotPaintCapability.

Load migration generalizes legacy -> slots across slab/ceiling (surface),
fence (posts/infill/base/rail), column (shaft/base/capital/frame), shelf
(shelves/frame/back), and stair (per-role tread/side/railing). Library/
scene refs pass through; inline customs mint a deduped scene material;
legacy fields cleared. No visual change (renderers already fell back to
the legacy fields). Roof/chimney/dormer/vents intentionally stay on their
role system.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-18 10:12:53 -04:00
co-authored by Claude Opus 4.8
parent faf73d66f2
commit c6423e7c73
11 changed files with 659 additions and 162 deletions
+20
View File
@@ -2,7 +2,9 @@ import {
type AnyNodeId,
DEFAULT_WALL_HEIGHT,
getMaterialPresetByRef,
parseMaterialRef,
resolveMaterial,
type SceneMaterialId,
useScene,
type WallMoveBridgePlan,
type WallNode,
@@ -109,7 +111,25 @@ function wallSegmentExists(
)
}
// Resolve a wall slot ref (`library:`/`scene:`) to a swatch colour, or
// undefined when the ref is absent / dangling / colourless.
function resolveWallSlotRefColor(ref: string | undefined): string | undefined {
const parsed = parseMaterialRef(ref)
if (!parsed) return undefined
if (parsed.kind === 'library') {
return getMaterialPresetByRef(ref)?.mapProperties.color ?? undefined
}
const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
return sceneMaterial ? resolveMaterial(sceneMaterial.material).color : undefined
}
export function getWallGhostColor(wall: WallNode) {
const slotColor =
resolveWallSlotRefColor(wall.slots?.interior) ?? resolveWallSlotRefColor(wall.slots?.exterior)
if (slotColor) {
return slotColor
}
const presetColor =
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
+40 -61
View File
@@ -1,14 +1,15 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveWallSurfaceMaterial,
type MaterialSchema,
type PaintCapability,
type PaintPreviewArgs,
sceneRegistry,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core'
import { getVisibleWallMaterials } from '@pascal-app/viewer'
import type { Material, Mesh } from 'three'
import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint'
/**
* Resolve which side of a wall the user clicked. Walls expose two
@@ -56,81 +57,59 @@ export function resolveWallRole(args: {
return hitFace === 'front' ? 'interior' : 'exterior'
}
export function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
// The wall's 3-material array maps side → group index (see
// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior.
const WALL_SIDE_MATERIAL_INDEX: Record<WallSurfaceSide, 1 | 2> = {
interior: 1,
exterior: 2,
}
/**
* Apply a preview to the wall's registered mesh by synthesising the
* post-paint node, asking the viewer's `getVisibleWallMaterials` for
* the corresponding material array, and swapping the mesh's
* material assignment until the editor calls the returned cleanup.
* Preview a wall paint by swapping just the painted face's entry in the wall
* mesh's material array. The array is the shared cached `WallMaterials.visible`,
* so we clone it before swapping and restore the original reference on cleanup
* (never mutate the cache).
*/
function applyWallPreview(
node: WallNode,
role: WallSurfaceSide,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): (() => void) | null {
const mesh = sceneRegistry.nodes.get(node.id as AnyNodeId)
function applyWallPreview(args: PaintPreviewArgs): (() => void) | null {
const { role, material, materialPreset } = args
const side = role as WallSurfaceSide
const index = WALL_SIDE_MATERIAL_INDEX[side]
if (!index) return null
const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId)
if (!(mesh && (mesh as Mesh).isMesh)) return null
const wallMesh = mesh as Mesh
const previewNode: WallNode = {
...node,
...buildWallSurfaceMaterialPatch(node, role, material, materialPreset),
}
const nextMaterial = getVisibleWallMaterials(previewNode)
if (!nextMaterial) return null
const current = wallMesh.material
if (!Array.isArray(current)) return null
const preview = buildSlotPreviewMaterial(material, materialPreset)
if (!preview) return () => {}
const previous = current as Material[]
const next = previous.slice()
next[index] = preview
wallMesh.material = next
const previousMaterial = wallMesh.material as Material | Material[]
wallMesh.material = nextMaterial
return () => {
wallMesh.material = previousMaterial
wallMesh.material = previous
}
}
/**
* Capability binding for the wall kind. The editor's
* selection-manager invokes these in place of the legacy
* `if (node.type === 'wall') { ... }` arms.
* Capability binding for the wall kind on the unified slot model. Painting
* writes `node.slots[interior|exterior]` (a `library:` ref or a minted
* `scene:` material) exactly like every other kind; `legacyEffective` reads
* the retired inline `interiorMaterial*` / `exteriorMaterial*` fields so the
* picker still shows the current value on a pre-migration scene.
*/
export const wallPaint: PaintCapability = {
export const wallPaint: PaintCapability = createSlotPaintCapability({
resolveRole: ({ node, materialIndex, normal, localPosition }) =>
resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }),
buildPatch: ({ node, role, material, materialPreset }) =>
buildWallSurfaceMaterialPatch(
node as WallNode,
role as WallSurfaceSide,
material,
materialPreset,
),
applyPreview: ({ node, role, material, materialPreset }) =>
applyWallPreview(node as WallNode, role as WallSurfaceSide, material, materialPreset),
getEffectiveMaterial: ({ node, role }) => {
applyPreview: applyWallPreview,
legacyEffective: (node: AnyNode, role: string) => {
const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide)
if (spec.material === undefined && spec.materialPreset === undefined) return null
return { material: spec.material, materialPreset: spec.materialPreset }
},
}
})
+13 -1
View File
@@ -49,7 +49,19 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
const material = getVisibleWallMaterials(node, shading, textures, colorPreset, sceneTheme)
// Subscribe to the scene-material palette so editing a `scene:` material a
// wall slot references re-renders the wall live (the wall-system geometry
// dirty loop never fires for a material-only edit). `getMaterialsForWall`'s
// content hash keeps unaffected walls on their cached materials.
const sceneMaterials = useScene((s) => s.materials)
const material = getVisibleWallMaterials(
node,
shading,
textures,
colorPreset,
sceneTheme,
sceneMaterials,
)
return (
<mesh
+4 -6
View File
@@ -1,13 +1,11 @@
import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core'
/**
* A wall exposes two paintable faces — interior + exterior. Painting still
* writes the legacy `interiorMaterial*` / `exteriorMaterial*` fields via
* `wallPaint` (the inline model isn't migrated to `node.slots` yet); this
* A wall exposes two paintable faces — interior + exterior. Painting writes
* `node.slots[interior|exterior]` via `wallPaint` like every other kind; this
* declaration surfaces the slot list + declared defaults for the picker and
* keeps walls on the same `{ slotId, label, default }` contract as every other
* paintable kind. The defaults come from core so the viewer's material
* resolver renders the identical value.
* keeps walls on the same `{ slotId, label, default }` contract. The defaults
* come from core so the viewer's material resolver renders the identical value.
*/
export function wallSlots(): SlotDeclaration[] {
return [