feat(paint-slots): unified slot defaults + paint for slab, ceiling, wall (phase 5)
Brings slab, ceiling, and wall onto the unified slot contract the shelf established, so each declares its paintable slots with a declarative default and (slab/ceiling) is painted through the registry capabilities.paint dispatch. - Shared helper packages/nodes/src/shared/slot-paint.ts: a node.slots-based PaintCapability factory (commit/resolve/effective generic; preview injected). Distinct from surface-paint.ts, which writes the legacy inline node.material. - slab: schema slots; def.geometry resolves node.slots.surface -> legacy material -> declared default, tags the mesh userData.slotId; slabPaint + capabilities.slots. Retires DEFAULT_SLAB_MATERIAL in the slab path. - ceiling: schema slots; material builders extracted to ceiling/materials.ts (shared by renderer + paint preview, built BackSide so the hover preview is visible from below); renderer resolves the slot; ceilingPaint + slots. - wall: WALL_SLOT_DEFAULT in core; the viewer's getMaterialsForWall renders an unpainted face with its declared default instead of the themed wall role; capabilities.slots (interior/exterior). wallPaint's inline interior/exterior fields are unchanged (node.slots migration is a later step). - selection-manager + material-paint: drop slab/ceiling from the legacy single-surface arms (now registry-driven). Behavior change (intended, matches the shelf precedent + the phase-5 plan): colored-mode UNPAINTED slab/ceiling/wall surfaces now render their fixed slot default (#e5e5e5 / #f5f5dc / #ffffff) instead of the theme role colour. The textures-off (monochrome) role collapse is unchanged — the escape hatch. 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
b0f4e1b8ee
commit
967a905e3b
@@ -154,6 +154,7 @@ export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
|
||||
export {
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getWallSurfaceMaterialSignature,
|
||||
WALL_SLOT_DEFAULT,
|
||||
WallNode,
|
||||
} from './nodes/wall'
|
||||
export { WindowNode, WindowType } from './nodes/window'
|
||||
|
||||
@@ -11,6 +11,10 @@ export const CeilingNode = BaseNode.extend({
|
||||
children: z.array(ItemNode.shape.id).default([]),
|
||||
material: MaterialSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
// Per-slot material overrides on the unified slot model, mirroring
|
||||
// `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef`
|
||||
// (`library:<id>` / `scene:<id>`). Absent = the declared slot default.
|
||||
slots: z.record(z.string(), z.string()).optional(),
|
||||
polygon: z.array(z.tuple([z.number(), z.number()])),
|
||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
|
||||
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
|
||||
|
||||
@@ -9,6 +9,10 @@ export const SlabNode = BaseNode.extend({
|
||||
type: nodeType('slab'),
|
||||
material: MaterialSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
// Per-slot material overrides on the unified slot model, mirroring
|
||||
// `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef`
|
||||
// (`library:<id>` / `scene:<id>`). Absent = the declared slot default.
|
||||
slots: z.record(z.string(), z.string()).optional(),
|
||||
polygon: z.array(z.tuple([z.number(), z.number()])),
|
||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
|
||||
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
|
||||
|
||||
@@ -46,6 +46,16 @@ export type WallNode = z.infer<typeof WallNode>
|
||||
|
||||
export type WallSurfaceSide = 'interior' | 'exterior'
|
||||
|
||||
// Declared default appearance for an unpainted wall face in colored mode —
|
||||
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
|
||||
// slot declaration (nodes) and the material resolver (viewer) share one value.
|
||||
// May be a `#rrggbb` colour or a `library:<id>` ref. Textures-off still
|
||||
// collapses to the themed wall role (the escape hatch).
|
||||
export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
|
||||
interior: '#ffffff',
|
||||
exterior: '#ffffff',
|
||||
}
|
||||
|
||||
export type WallSurfaceMaterialSpec = {
|
||||
material?: z.infer<typeof MaterialSchema>
|
||||
materialPreset?: string
|
||||
|
||||
@@ -1059,13 +1059,7 @@ export const SelectionManager = () => {
|
||||
// before any of the legacy roof / stair / single-surface arms
|
||||
// below run.
|
||||
|
||||
if (
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'shelf'
|
||||
) {
|
||||
if (node.type === 'fence' || node.type === 'column' || node.type === 'shelf') {
|
||||
const compatible = paintEnabled
|
||||
|
||||
return {
|
||||
|
||||
@@ -382,8 +382,6 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
||||
if (
|
||||
(selectedNode.type === 'fence' ||
|
||||
selectedNode.type === 'column' ||
|
||||
selectedNode.type === 'slab' ||
|
||||
selectedNode.type === 'ceiling' ||
|
||||
selectedNode.type === 'shelf') &&
|
||||
selectedMaterialTarget.role === 'surface'
|
||||
) {
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
ceilingMoveVertexAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { ceilingFloorplanMoveTarget } from './floorplan-move'
|
||||
import { ceilingPaint } from './paint'
|
||||
import { ceilingParametrics } from './parametrics'
|
||||
import { CeilingNode } from './schema'
|
||||
import { ceilingSlots } from './slots'
|
||||
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const MIN_CEILING_HEIGHT = 0.5
|
||||
@@ -102,6 +104,10 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
},
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
// Unified slot model: one paintable underside surface with a declared
|
||||
// default, painted through the registry `capabilities.paint` dispatch.
|
||||
slots: () => ceilingSlots(),
|
||||
paint: ceilingPaint,
|
||||
},
|
||||
|
||||
relations: {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
getMaterialPresetByRef,
|
||||
parseMaterialRef,
|
||||
resolveMaterial,
|
||||
type SceneMaterial,
|
||||
type SceneMaterialId,
|
||||
} from '@pascal-app/core'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
import { BackSide, FrontSide, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
|
||||
/**
|
||||
* Ceiling material builders, shared by the renderer (mesh appearance) and the
|
||||
* paint capability (hover preview). A ceiling is a flat tinted surface: the
|
||||
* underside (`bottom`, seen from inside the room, `BackSide`) is opaque, while
|
||||
* the `top` carries a transparent TSL grid overlay used while placing /
|
||||
* selecting ceiling-hosted items. Both derive from a single colour, so slot
|
||||
* painting resolves a colour and rebuilds these — it never applies a PBR map.
|
||||
*/
|
||||
|
||||
const gridScale = 5
|
||||
const gridX = positionWorld.x.mul(gridScale).fract()
|
||||
const gridY = positionWorld.z.mul(gridScale).fract()
|
||||
const lineWidth = 0.05
|
||||
const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))
|
||||
const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))
|
||||
const gridPattern = lineX.max(lineY)
|
||||
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
|
||||
|
||||
export type CeilingMaterials = {
|
||||
topMaterial: MeshBasicNodeMaterial
|
||||
bottomMaterial: MeshBasicNodeMaterial
|
||||
}
|
||||
|
||||
function createCeilingMaterials(color = '#999999'): CeilingMaterials {
|
||||
const topMaterial = new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: FrontSide,
|
||||
})
|
||||
topMaterial.opacityNode = gridOpacity
|
||||
|
||||
const bottomMaterial = new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
side: BackSide,
|
||||
})
|
||||
|
||||
return { topMaterial, bottomMaterial }
|
||||
}
|
||||
|
||||
const ceilingMaterialCache = new Map<string, CeilingMaterials>()
|
||||
|
||||
export function getCeilingMaterials(color = '#999999'): CeilingMaterials {
|
||||
const cached = ceilingMaterialCache.get(color)
|
||||
if (cached) return cached
|
||||
const materials = createCeilingMaterials(color)
|
||||
ceilingMaterialCache.set(color, materials)
|
||||
return materials
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a slot `MaterialRef` to a flat colour for the ceiling surface.
|
||||
* `library:` refs use the catalog preset's base colour; `scene:` refs use the
|
||||
* stored material's colour. Returns null for a dangling / unparseable ref so
|
||||
* the caller falls back to its default.
|
||||
*/
|
||||
export function ceilingColorFromRef(
|
||||
ref: string | undefined,
|
||||
sceneMaterials: Record<SceneMaterialId, SceneMaterial> | undefined,
|
||||
): string | null {
|
||||
const parsed = parseMaterialRef(ref)
|
||||
if (!parsed) return null
|
||||
if (parsed.kind === 'library') {
|
||||
return getMaterialPresetByRef(ref)?.mapProperties.color ?? null
|
||||
}
|
||||
const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId]
|
||||
if (!sceneMaterial) return null
|
||||
return resolveMaterial(sceneMaterial.material).color ?? null
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type CeilingNode,
|
||||
getMaterialPresetByRef,
|
||||
resolveMaterial,
|
||||
} from '@pascal-app/core'
|
||||
import type { Mesh } from 'three'
|
||||
import { createSlotPaintCapability } from '../shared/slot-paint'
|
||||
import { getCeilingMaterials } from './materials'
|
||||
|
||||
/**
|
||||
* Ceiling paint on the unified slot model. A ceiling has one paintable surface,
|
||||
* so every hit resolves to `surface`; commit writes `node.slots.surface`. The
|
||||
* preview swaps the registered underside mesh to the ceiling's own flat-tinted
|
||||
* material (built `BackSide`, the way it renders), so the hover preview matches
|
||||
* the committed result — a generic PBR preview would be invisible from below.
|
||||
*/
|
||||
export const ceilingPaint = createSlotPaintCapability({
|
||||
resolveRole: () => 'surface',
|
||||
applyPreview: ({ material, materialPreset, root }) => {
|
||||
const color = materialPreset
|
||||
? (getMaterialPresetByRef(materialPreset)?.mapProperties.color ?? null)
|
||||
: material
|
||||
? (resolveMaterial(material).color ?? null)
|
||||
: null
|
||||
if (!color) return () => {}
|
||||
const mesh = root as Mesh
|
||||
if (!mesh.isMesh) return null
|
||||
const previous = mesh.material
|
||||
mesh.material = getCeilingMaterials(color).bottomMaterial
|
||||
return () => {
|
||||
mesh.material = previous
|
||||
}
|
||||
},
|
||||
legacyEffective: (node: AnyNode) => {
|
||||
const ceiling = node as CeilingNode
|
||||
if (ceiling.materialPreset || ceiling.material) {
|
||||
return { material: ceiling.material, materialPreset: ceiling.materialPreset }
|
||||
}
|
||||
return null
|
||||
},
|
||||
})
|
||||
@@ -15,53 +15,15 @@ import {
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { BackSide, type Mesh } from 'three/webgpu'
|
||||
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
|
||||
import { ceilingColorFromRef, getCeilingMaterials } from './materials'
|
||||
import { CEILING_SLOT_DEFAULT_COLOR } from './slots'
|
||||
|
||||
function createEmptyGeometry() {
|
||||
return createPlaceholderGeometry()
|
||||
}
|
||||
|
||||
const gridScale = 5
|
||||
const gridX = positionWorld.x.mul(gridScale).fract()
|
||||
const gridY = positionWorld.z.mul(gridScale).fract()
|
||||
const lineWidth = 0.05
|
||||
const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))
|
||||
const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))
|
||||
const gridPattern = lineX.max(lineY)
|
||||
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
|
||||
|
||||
function createCeilingMaterials(color = '#999999') {
|
||||
const topMaterial = new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: FrontSide,
|
||||
})
|
||||
topMaterial.opacityNode = gridOpacity
|
||||
|
||||
const bottomMaterial = new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
side: BackSide,
|
||||
})
|
||||
|
||||
return { topMaterial, bottomMaterial }
|
||||
}
|
||||
|
||||
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
|
||||
|
||||
function getCeilingMaterials(color = '#999999') {
|
||||
const cacheKey = color
|
||||
const cached = ceilingMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const materials = createCeilingMaterials(color)
|
||||
ceilingMaterialCache.set(cacheKey, materials)
|
||||
return materials
|
||||
}
|
||||
|
||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
@@ -80,6 +42,9 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
// Subscribe to the scene-material library so editing a `scene:` material the
|
||||
// ceiling slot references re-tints it live.
|
||||
const sceneMaterials = useScene((s) => s.materials)
|
||||
const liveTransform = useLiveTransforms((s) => s.get(node.id))
|
||||
const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0)
|
||||
const position: [number, number, number] = [
|
||||
@@ -97,18 +62,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
)
|
||||
|
||||
const materials = useMemo(() => {
|
||||
// Untextured ceilings (and everything in textures-off mode) take the themed
|
||||
// 'ceiling' role colour; only an explicit preset/material keeps a texture.
|
||||
const hasExplicit = Boolean(node.materialPreset || node.material)
|
||||
if (!textures || !hasExplicit) {
|
||||
// Bottom (seen from inside the room, looking up) stays opaque so the
|
||||
// ceiling reads as a solid surface. Top uses the transparent
|
||||
// grid-pattern material so the ceiling stays see-through whenever
|
||||
// the editor reveals the `ceiling-grid` overlay (placing a
|
||||
// ceiling-hosted item, or selecting one of its children — e.g.
|
||||
// after committing a placement). Without this the top mesh shipped
|
||||
// an opaque surface-role material, so a top-down camera lost view
|
||||
// of everything under the ceiling once the overlay turned on.
|
||||
// Textures-off mode takes the themed 'ceiling' role colour — the guaranteed
|
||||
// escape hatch, independent of any slot override. The bottom (seen from
|
||||
// inside the room, looking up) stays opaque so the ceiling reads as a solid
|
||||
// surface; the top keeps the transparent grid material so a top-down camera
|
||||
// can see through the ceiling whenever the `ceiling-grid` overlay is
|
||||
// revealed (placing a ceiling-hosted item, or selecting one of its
|
||||
// children). Without that the top mesh would ship an opaque surface-role
|
||||
// material and a top-down camera would lose everything under the ceiling.
|
||||
if (!textures) {
|
||||
const ceilingColor = resolveSurfaceColor('ceiling', colorPreset, sceneTheme)
|
||||
return {
|
||||
topMaterial: getCeilingMaterials(ceilingColor).topMaterial,
|
||||
@@ -116,14 +78,26 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
const color = props.color || '#999999'
|
||||
return getCeilingMaterials(color)
|
||||
// Unified slot override — shared scene material or catalog `library:` finish
|
||||
// (resolved to its base colour; a ceiling renders flat-tinted, not mapped).
|
||||
const slotColor = ceilingColorFromRef(node.slots?.surface, sceneMaterials)
|
||||
if (slotColor) return getCeilingMaterials(slotColor)
|
||||
|
||||
// Legacy inline material / preset (scenes painted before the slot model).
|
||||
if (node.materialPreset || node.material) {
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
return getCeilingMaterials(props.color || '#999999')
|
||||
}
|
||||
|
||||
// Declared slot default.
|
||||
return getCeilingMaterials(CEILING_SLOT_DEFAULT_COLOR)
|
||||
}, [
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
sceneMaterials,
|
||||
node.slots,
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SlotDeclaration } from '@pascal-app/core'
|
||||
|
||||
export type CeilingSlotId = 'surface'
|
||||
|
||||
// Visual parity with the retired DEFAULT_CEILING_MATERIAL (warm beige).
|
||||
export const CEILING_SLOT_DEFAULT_COLOR = '#f5f5dc'
|
||||
|
||||
/** A ceiling exposes a single paintable underside surface. */
|
||||
export function ceilingSlots(): SlotDeclaration[] {
|
||||
return [{ slotId: 'surface', label: 'Surface', default: CEILING_SLOT_DEFAULT_COLOR }]
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
generateSceneMaterialId,
|
||||
type MaterialSchema,
|
||||
type PaintCapability,
|
||||
type PaintPreviewArgs,
|
||||
type PaintResolveArgs,
|
||||
parseMaterialRef,
|
||||
type SceneMaterial,
|
||||
type SceneMaterialId,
|
||||
toSceneMaterialRef,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer'
|
||||
import type { Material, Mesh, Object3D } from 'three'
|
||||
|
||||
/**
|
||||
* Shared paint capability for procedural kinds on the unified slot model
|
||||
* (`node.slots: Record<slotId, MaterialRef>` + the shared scene-material
|
||||
* palette) — the same data shape items derive from their GLB and the shelf
|
||||
* declares via `capabilities.slots`. Distinct from `surface-paint.ts`, which
|
||||
* writes the legacy inline `node.material` copy the plan is retiring.
|
||||
*
|
||||
* The commit / resolve / effective-material logic is identical across kinds;
|
||||
* only the slot-resolution from a pointer hit and the mesh preview differ, so
|
||||
* those are injected per kind.
|
||||
*/
|
||||
|
||||
type SlotsNode = AnyNode & { slots?: Record<string, string> }
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (Object.is(a, b)) return true
|
||||
if (typeof a !== typeof b) return false
|
||||
if (a === null || b === null) return false
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
if (!deepEqual(a[index], b[index])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (typeof a === 'object') {
|
||||
const aRecord = a as Record<string, unknown>
|
||||
const bRecord = b as Record<string, unknown>
|
||||
const aKeys = Object.keys(aRecord)
|
||||
const bKeys = Object.keys(bRecord)
|
||||
if (aKeys.length !== bKeys.length) return false
|
||||
for (const key of aKeys) {
|
||||
if (!Object.hasOwn(bRecord, key)) return false
|
||||
if (!deepEqual(aRecord[key], bRecord[key])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function findMatchingSceneMaterial(
|
||||
materials: Record<SceneMaterialId, SceneMaterial>,
|
||||
material: MaterialSchema,
|
||||
): SceneMaterial | null {
|
||||
for (const sceneMaterial of Object.values(materials)) {
|
||||
if (deepEqual(sceneMaterial.material, material)) return sceneMaterial
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function commitSlotPaint(
|
||||
node: SlotsNode,
|
||||
role: string,
|
||||
material: MaterialSchema | undefined,
|
||||
materialPreset: string | undefined,
|
||||
): void {
|
||||
const nodeId = node.id as AnyNodeId
|
||||
const state = useScene.getState()
|
||||
const currentNode = (state.nodes[nodeId] as SlotsNode | undefined) ?? node
|
||||
|
||||
let ref: string | undefined
|
||||
let newSceneMaterial: SceneMaterial | null = null
|
||||
|
||||
if (material === undefined && materialPreset === undefined) {
|
||||
ref = undefined
|
||||
} else if (materialPreset) {
|
||||
ref = materialPreset
|
||||
} else if (material) {
|
||||
const existing = findMatchingSceneMaterial(state.materials, material)
|
||||
if (existing) {
|
||||
ref = toSceneMaterialRef(existing.id)
|
||||
} else {
|
||||
const id = generateSceneMaterialId()
|
||||
newSceneMaterial = {
|
||||
id,
|
||||
name: `Material ${Object.keys(state.materials).length + 1}`,
|
||||
material,
|
||||
}
|
||||
ref = toSceneMaterialRef(id)
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
const nextSlots = { ...(currentNode.slots ?? {}) }
|
||||
if (ref) nextSlots[role] = ref
|
||||
else delete nextSlots[role]
|
||||
|
||||
if (newSceneMaterial) {
|
||||
// Creating the scene material and setting the slot ref are one logical
|
||||
// edit, so apply both in a single `set` — zundo records one history entry,
|
||||
// and one undo removes both the ref and its (now orphaned) material.
|
||||
const sceneMaterial = newSceneMaterial
|
||||
useScene.setState((s) => {
|
||||
if (s.readOnly) return s
|
||||
const node2 = s.nodes[nodeId] as SlotsNode | undefined
|
||||
if (!node2) return s
|
||||
return {
|
||||
materials: { ...s.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial },
|
||||
nodes: {
|
||||
...s.nodes,
|
||||
[nodeId]: { ...node2, slots: nextSlots } as AnyNode,
|
||||
},
|
||||
}
|
||||
})
|
||||
useScene.getState().markDirty(nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
state.updateNode(nodeId, { slots: nextSlots } as Partial<AnyNode>)
|
||||
}
|
||||
|
||||
/** Preview material for a slot paint — mirrors the commit's resolution. */
|
||||
export function buildSlotPreviewMaterial(
|
||||
material: MaterialSchema | undefined,
|
||||
materialPreset: string | undefined,
|
||||
): Material | null {
|
||||
const shading = useViewer.getState().shading
|
||||
if (materialPreset) return createMaterialFromPresetRef(materialPreset, shading)
|
||||
if (material) return createMaterial(material, shading)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview for kinds whose meshes are produced by `def.geometry` and tagged
|
||||
* with `userData.slotId` (+ `__fromGeometry`). Swaps every builder mesh whose
|
||||
* slot matches `role`, leaving hosted-child meshes (which can carry a colliding
|
||||
* `userData.slotId` from their own GLB) untouched.
|
||||
*/
|
||||
export function previewGeometrySlot(args: PaintPreviewArgs): (() => void) | null {
|
||||
const { role, root, material, materialPreset } = args
|
||||
const preview = buildSlotPreviewMaterial(material, materialPreset)
|
||||
if (!preview) return () => {}
|
||||
|
||||
const restores: Array<() => void> = []
|
||||
;(root as Object3D).traverse((object) => {
|
||||
const mesh = object as Mesh
|
||||
if (!mesh.isMesh) return
|
||||
const userData = mesh.userData as { slotId?: string | null; __fromGeometry?: boolean }
|
||||
if (userData.__fromGeometry !== true) return
|
||||
if (userData.slotId !== role) return
|
||||
const previous = mesh.material
|
||||
mesh.material = preview
|
||||
restores.push(() => {
|
||||
mesh.material = previous
|
||||
})
|
||||
})
|
||||
|
||||
if (restores.length === 0) return null
|
||||
return () => {
|
||||
for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.()
|
||||
}
|
||||
}
|
||||
|
||||
export type SlotPaintConfig = {
|
||||
/** Resolve the slot id for a pointer hit (`null` = not paintable here). */
|
||||
resolveRole: (args: PaintResolveArgs) => string | null
|
||||
/** Apply a preview to the registered mesh subtree for `role`. */
|
||||
applyPreview: (args: PaintPreviewArgs) => (() => void) | null
|
||||
/**
|
||||
* Optional legacy fallback for the picker's current-value indicator — read
|
||||
* when no `node.slots[role]` ref exists yet (e.g. a scene painted before the
|
||||
* kind moved onto the slot model still carries inline `material`/`preset`).
|
||||
*/
|
||||
legacyEffective?: (
|
||||
node: AnyNode,
|
||||
role: string,
|
||||
) => { material: MaterialSchema | undefined; materialPreset: string | undefined } | null
|
||||
}
|
||||
|
||||
export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability {
|
||||
return {
|
||||
resolveRole: config.resolveRole,
|
||||
buildPatch: ({ node, role, materialPreset }) => {
|
||||
const slots = { ...((node as SlotsNode).slots ?? {}) }
|
||||
if (materialPreset) slots[role] = materialPreset
|
||||
else delete slots[role]
|
||||
return { slots } as Partial<AnyNode>
|
||||
},
|
||||
commit: ({ node, role, material, materialPreset }) =>
|
||||
commitSlotPaint(node as SlotsNode, role, material, materialPreset),
|
||||
applyPreview: config.applyPreview,
|
||||
getEffectiveMaterial: ({ node, role }) => {
|
||||
const ref = (node as SlotsNode).slots?.[role]
|
||||
const parsed = parseMaterialRef(ref)
|
||||
if (parsed) {
|
||||
if (parsed.kind === 'library') return { material: undefined, materialPreset: ref }
|
||||
const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
|
||||
if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined }
|
||||
}
|
||||
return config.legacyEffective?.(node, role) ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
} from './floorplan-affordances'
|
||||
import { slabFloorplanMoveTarget } from './floorplan-move'
|
||||
import { buildSlabGeometry } from './geometry'
|
||||
import { slabPaint } from './paint'
|
||||
import { slabParametrics } from './parametrics'
|
||||
import { SlabNode } from './schema'
|
||||
import { slabSlots } from './slots'
|
||||
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const MIN_SLAB_ELEVATION = 0.02
|
||||
@@ -155,6 +157,10 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
},
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
// Unified slot model: one paintable floor surface with a declared default,
|
||||
// painted through the registry `capabilities.paint` dispatch like the shelf.
|
||||
slots: () => slabSlots(),
|
||||
paint: slabPaint,
|
||||
},
|
||||
|
||||
relations: {
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core'
|
||||
import { type GeometryContext, getMaterialPresetByRef, type SlabNode } from '@pascal-app/core'
|
||||
import {
|
||||
applyMaterialPresetToMaterials,
|
||||
type ColorPreset,
|
||||
createDefaultMaterial,
|
||||
createMaterial,
|
||||
createSurfaceRoleMaterial,
|
||||
DEFAULT_SLAB_MATERIAL,
|
||||
generateSlabGeometry,
|
||||
type RenderShading,
|
||||
resolveMaterialRef,
|
||||
} from '@pascal-app/viewer'
|
||||
import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
|
||||
import { SLAB_SLOT_DEFAULT_COLOR } from './slots'
|
||||
|
||||
/**
|
||||
* Stage B builder for slab. Reuses `generateSlabGeometry` (pure
|
||||
* triangulation + hole CSG from viewer) and the same material cache
|
||||
* pattern the legacy slab renderer used.
|
||||
*
|
||||
* Materials are cached by `{material, materialPreset}` signature so
|
||||
* slabs sharing settings share the GPU resource. Cached entry mutation
|
||||
* (preset apply) is preserved — async texture loads still update the
|
||||
* rendered material after re-mount.
|
||||
* 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 colour. Textures-off collapses to the themed
|
||||
* `floor` role — the guaranteed monochrome escape hatch.
|
||||
*/
|
||||
type SlabMaterial = Material & {
|
||||
alphaMap?: Texture | null
|
||||
@@ -35,19 +37,39 @@ function getSlabMaterial(
|
||||
shading: RenderShading,
|
||||
textures: boolean,
|
||||
colorPreset: ColorPreset,
|
||||
sceneTheme?: string,
|
||||
sceneTheme: string | undefined,
|
||||
sceneMaterials: GeometryContext['materials'],
|
||||
): Material {
|
||||
// Untextured slabs (and everything in textures-off mode) take the themed
|
||||
// 'floor' role colour. createSurfaceRoleMaterial returns a shared cached
|
||||
// material, so it is returned as-is without the mutation below.
|
||||
// FrontSide — DoubleSide on the role material's NodeMaterial poisons the
|
||||
// MRT scene pass (see `materials.ts` line 77 / glazing fix 9400f1c5).
|
||||
// Slab side faces still render correctly because `generateSlabGeometry`
|
||||
// produces outward-facing normals on the top, bottom, and perimeter.
|
||||
if (!textures || (!node.materialPreset && !node.material)) {
|
||||
// Textures-off mode takes the themed 'floor' role colour — the guaranteed
|
||||
// escape hatch, independent of any slot override. createSurfaceRoleMaterial
|
||||
// returns a shared cached material. FrontSide — DoubleSide on the role
|
||||
// material's NodeMaterial poisons the MRT scene pass (see `materials.ts`
|
||||
// line 77 / glazing fix 9400f1c5). Slab side faces still render correctly
|
||||
// because `generateSlabGeometry` produces outward-facing normals.
|
||||
if (!textures) {
|
||||
return createSurfaceRoleMaterial('floor', colorPreset, FrontSide, sceneTheme)
|
||||
}
|
||||
|
||||
// Unified slot override — shared scene material or catalog `library:` finish.
|
||||
const slotRef = node.slots?.surface
|
||||
if (slotRef) {
|
||||
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
// Legacy inline material / preset, for scenes painted before the slot model.
|
||||
if (node.materialPreset || node.material) {
|
||||
return getLegacySlabMaterial(node, shading)
|
||||
}
|
||||
|
||||
// Declared slot default (visual parity with the retired DEFAULT_SLAB_MATERIAL).
|
||||
return createDefaultMaterial(SLAB_SLOT_DEFAULT_COLOR, 0.8, shading)
|
||||
}
|
||||
|
||||
function getLegacySlabMaterial(node: SlabNode, shading: RenderShading): Material {
|
||||
// Cached by `{material, materialPreset}` signature so slabs sharing settings
|
||||
// share the GPU resource; cached entry mutation (preset apply) is preserved
|
||||
// so async texture loads still update the rendered material after re-mount.
|
||||
const cacheKey = JSON.stringify({
|
||||
shading,
|
||||
material: node.material ?? null,
|
||||
@@ -61,7 +83,7 @@ function getSlabMaterial(
|
||||
? createDefaultMaterial('#ffffff', 0.5, shading)
|
||||
: node.material
|
||||
? createMaterial(node.material, shading).clone()
|
||||
: DEFAULT_SLAB_MATERIAL(shading).clone()
|
||||
: createDefaultMaterial(SLAB_SLOT_DEFAULT_COLOR, 0.8, shading)
|
||||
|
||||
if (preset) {
|
||||
applyMaterialPresetToMaterials(material, preset)
|
||||
@@ -84,7 +106,7 @@ function getSlabMaterial(
|
||||
|
||||
export function buildSlabGeometry(
|
||||
node: SlabNode,
|
||||
_ctx?: unknown,
|
||||
ctx?: GeometryContext,
|
||||
shading: RenderShading = 'rendered',
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
@@ -92,10 +114,12 @@ export function buildSlabGeometry(
|
||||
): Group {
|
||||
const group = new Group()
|
||||
const geometry = generateSlabGeometry(node)
|
||||
const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme)
|
||||
const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme, ctx?.materials)
|
||||
const mesh = new Mesh(geometry, material)
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
// Tag the surface so the unified slot paint can resolve the hit and preview.
|
||||
mesh.userData.slotId = 'surface'
|
||||
const elevation = node.elevation ?? 0.05
|
||||
if (elevation < 0) mesh.position.y = elevation
|
||||
group.add(mesh)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AnyNode, SlabNode } from '@pascal-app/core'
|
||||
import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint'
|
||||
|
||||
/**
|
||||
* Slab paint on the unified slot model. A slab has one paintable surface, so
|
||||
* every face resolves to the `surface` slot; commit writes `node.slots.surface`
|
||||
* (a shared scene-material or `library:` ref) like the shelf.
|
||||
*/
|
||||
export const slabPaint = createSlotPaintCapability({
|
||||
resolveRole: () => 'surface',
|
||||
applyPreview: previewGeometrySlot,
|
||||
legacyEffective: (node: AnyNode) => {
|
||||
const slab = node as SlabNode
|
||||
if (slab.materialPreset || slab.material) {
|
||||
return { material: slab.material, materialPreset: slab.materialPreset }
|
||||
}
|
||||
return null
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SlotDeclaration } from '@pascal-app/core'
|
||||
|
||||
export type SlabSlotId = 'surface'
|
||||
|
||||
// Visual parity with the retired DEFAULT_SLAB_MATERIAL (light grey).
|
||||
export const SLAB_SLOT_DEFAULT_COLOR = '#e5e5e5'
|
||||
|
||||
/** A slab exposes a single paintable floor surface. */
|
||||
export function slabSlots(): SlotDeclaration[] {
|
||||
return [{ slotId: 'surface', label: 'Surface', default: SLAB_SLOT_DEFAULT_COLOR }]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
|
||||
import { wallPaint } from './paint'
|
||||
import { wallParametrics } from './parametrics'
|
||||
import { WallNode } from './schema'
|
||||
import { wallSlots } from './slots'
|
||||
|
||||
/**
|
||||
* Wall — the Phase 3 stress test of the registry-driven node model.
|
||||
@@ -56,6 +57,11 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
// preview through this entry rather than carrying a kind-name
|
||||
// arm.
|
||||
paint: wallPaint,
|
||||
// Declared paintable slots (interior / exterior) with their default
|
||||
// appearance — the same `{ slotId, label, default }` contract every other
|
||||
// paintable kind exposes. Paint still writes the legacy inline fields via
|
||||
// `wallPaint`; migrating those into `node.slots` is a later step.
|
||||
slots: () => wallSlots(),
|
||||
},
|
||||
|
||||
relations: {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
export function wallSlots(): SlotDeclaration[] {
|
||||
return [
|
||||
{ slotId: 'interior', label: 'Interior', default: WALL_SLOT_DEFAULT.interior },
|
||||
{ slotId: 'exterior', label: 'Exterior', default: WALL_SLOT_DEFAULT.exterior },
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import {
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getMaterialPresetByRef,
|
||||
getWallSurfaceMaterialSignature,
|
||||
parseMaterialRef,
|
||||
resolveMaterial,
|
||||
WALL_SLOT_DEFAULT,
|
||||
type WallNode,
|
||||
type WallSurfaceMaterialSpec,
|
||||
} from '@pascal-app/core'
|
||||
@@ -12,6 +14,7 @@ import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
baseMaterial,
|
||||
type ColorPreset,
|
||||
createDefaultMaterial,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
createSurfaceRoleMaterial,
|
||||
@@ -88,6 +91,15 @@ function hasExplicitMaterial(spec: WallSurfaceMaterialSpec): boolean {
|
||||
return Boolean(spec.materialPreset || spec.material)
|
||||
}
|
||||
|
||||
// Resolve a wall face's declared default — a catalog `library:` finish or a
|
||||
// flat colour — to a renderable material.
|
||||
function resolveWallSlotDefault(slotDefault: string, shading: RenderShading): Material {
|
||||
if (parseMaterialRef(slotDefault)?.kind === 'library') {
|
||||
return createMaterialFromPresetRef(slotDefault, shading) ?? baseMaterial(shading)
|
||||
}
|
||||
return createDefaultMaterial(slotDefault, 0.9, shading)
|
||||
}
|
||||
|
||||
function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string {
|
||||
const preset = getMaterialPresetByRef(spec.materialPreset)
|
||||
if (preset?.mapProperties?.color) {
|
||||
@@ -216,17 +228,19 @@ export function getMaterialsForWall(
|
||||
const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior')
|
||||
const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
|
||||
|
||||
// Untextured surfaces take the themed wall role colour even with textures on;
|
||||
// only surfaces with an explicit preset/material keep their texture.
|
||||
// Colored mode: an unpainted face takes its declared slot default (parity
|
||||
// with the retired DEFAULT_WALL_MATERIAL); only an explicit preset/material
|
||||
// keeps a texture. Textures-off collapses every face to the themed wall role
|
||||
// (the guaranteed escape hatch). The edge/cap slot (index 0) stays role-based.
|
||||
const visible: WallMaterialArray = textures
|
||||
? [
|
||||
wallRoleMaterial,
|
||||
hasExplicitMaterial(interiorSpec)
|
||||
? getSurfaceVisibleMaterial(interiorSpec, shading)
|
||||
: wallRoleMaterial,
|
||||
: resolveWallSlotDefault(WALL_SLOT_DEFAULT.interior, shading),
|
||||
hasExplicitMaterial(exteriorSpec)
|
||||
? getSurfaceVisibleMaterial(exteriorSpec, shading)
|
||||
: wallRoleMaterial,
|
||||
: resolveWallSlotDefault(WALL_SLOT_DEFAULT.exterior, shading),
|
||||
]
|
||||
: [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user