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
@@ -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 }]
|
||||
}
|
||||
Reference in New Issue
Block a user