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:
Wassim SAMAD
2026-06-17 07:51:37 -04:00
co-authored by Claude Opus 4.8
parent b0f4e1b8ee
commit 967a905e3b
19 changed files with 520 additions and 88 deletions
+6
View File
@@ -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: {
+80
View File
@@ -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
}
+42
View File
@@ -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
},
})
+31 -57
View File
@@ -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,
+11
View File
@@ -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 }]
}