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:
co-authored by
Claude Opus 4.8
parent
faf73d66f2
commit
c6423e7c73
@@ -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 ??
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user