feat(paint-slots): split slab into top + side slots

Slabs now expose two paintable faces instead of one: `surface` (top, keeps
the wood floor default and the prior slot id so painted slabs are
unaffected) and `side` (vertical walls + underside, default light grey).
The merged slab buffer is split by per-triangle face normal into a top mesh
and a side mesh, each tagged with its slot id for paint resolve/preview;
legacy whole-slab material maps onto the top only. Textures-off still
collapses to the floor role.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-18 12:05:51 -04:00
co-authored by Claude Opus 4.8
parent 6b67ab6949
commit d924931cee
3 changed files with 133 additions and 34 deletions
+98 -18
View File
@@ -10,8 +10,17 @@ import {
resolveMaterialRef,
resolveSlotDefaultMaterial,
} from '@pascal-app/viewer'
import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
import { SLAB_SLOT_DEFAULT } from './slots'
import {
BufferGeometry,
Float32BufferAttribute,
FrontSide,
Group,
type Material,
Mesh,
type Texture,
Vector3,
} from 'three'
import { SLAB_SIDE_SLOT_DEFAULT, SLAB_TOP_SLOT_DEFAULT, type SlabSlotId } from './slots'
/**
* Stage B builder for slab. Reuses `generateSlabGeometry` (pure
@@ -33,38 +42,92 @@ type SlabMaterial = Material & {
const slabMaterialCache = new Map<string, Material>()
function getSlabMaterial(
function getSlabSlotMaterial(
node: SlabNode,
slotId: SlabSlotId,
shading: RenderShading,
textures: boolean,
colorPreset: ColorPreset,
sceneTheme: string | undefined,
sceneMaterials: GeometryContext['materials'],
): 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.
// Textures-off mode takes the themed 'floor' role colour for every face — the
// guaranteed escape hatch, independent of any slot override. 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` emits 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
const slotRef = node.slots?.[slotId]
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) {
// Legacy inline material / preset (pre-slot-model scenes) applied to the whole
// slab — map it onto the top face only; sides take their own default.
if (slotId === 'surface' && (node.materialPreset || node.material)) {
return getLegacySlabMaterial(node, shading)
}
// Declared slot default — a catalog `library:` finish or a flat colour.
return resolveSlotDefaultMaterial(SLAB_SLOT_DEFAULT, shading, 0.8)
const slotDefault = slotId === 'side' ? SLAB_SIDE_SLOT_DEFAULT : SLAB_TOP_SLOT_DEFAULT
return resolveSlotDefaultMaterial(slotDefault, shading, 0.8)
}
// Split the merged slab buffer into top-facing (floor) and everything-else
// (vertical walls + underside) sub-geometries by per-triangle face normal, so
// the two paintable slots get distinct materials + raycast tags. De-indexes
// into per-face triangles (slabs are flat-shaded, so no shared-vertex seams).
function splitSlabFacesByFacing(geometry: BufferGeometry): {
top: BufferGeometry
side: BufferGeometry
} {
const position = geometry.getAttribute('position')
const uv = geometry.getAttribute('uv')
const index = geometry.getIndex()
const triangleCount = index ? index.count / 3 : position.count / 3
const top = { pos: [] as number[], uv: [] as number[] }
const side = { pos: [] as number[], uv: [] as number[] }
const a = new Vector3()
const b = new Vector3()
const c = new Vector3()
const ab = new Vector3()
const ac = new Vector3()
const normal = new Vector3()
for (let t = 0; t < triangleCount; t += 1) {
const i0 = index ? index.getX(t * 3) : t * 3
const i1 = index ? index.getX(t * 3 + 1) : t * 3 + 1
const i2 = index ? index.getX(t * 3 + 2) : t * 3 + 2
a.fromBufferAttribute(position, i0)
b.fromBufferAttribute(position, i1)
c.fromBufferAttribute(position, i2)
ab.subVectors(b, a)
ac.subVectors(c, a)
normal.crossVectors(ab, ac)
const lengthSq = normal.lengthSq()
const isTop = lengthSq > 1e-12 && normal.y / Math.sqrt(lengthSq) > 0.5
const target = isTop ? top : side
for (const i of [i0, i1, i2]) {
target.pos.push(position.getX(i), position.getY(i), position.getZ(i))
if (uv) target.uv.push(uv.getX(i), uv.getY(i))
}
}
const build = (data: { pos: number[]; uv: number[] }) => {
const geo = new BufferGeometry()
geo.setAttribute('position', new Float32BufferAttribute(data.pos, 3))
if (data.uv.length > 0) geo.setAttribute('uv', new Float32BufferAttribute(data.uv, 2))
geo.computeVertexNormals()
return geo
}
return { top: build(top), side: build(side) }
}
function getLegacySlabMaterial(node: SlabNode, shading: RenderShading): Material {
@@ -114,15 +177,32 @@ export function buildSlabGeometry(
sceneTheme?: string,
): Group {
const group = new Group()
const geometry = generateSlabGeometry(node)
const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme, ctx?.materials)
const merged = generateSlabGeometry(node)
const { top, side } = splitSlabFacesByFacing(merged)
merged.dispose()
const elevation = node.elevation ?? 0.05
// One mesh per slot, each tagged with its slot id so the unified slot paint
// resolves the hit (`resolveRole` reads `userData.slotId`) and previews it.
for (const [slotId, geometry] of [
['surface', top],
['side', side],
] as const) {
const material = getSlabSlotMaterial(
node,
slotId,
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
mesh.userData.slotId = slotId
if (elevation < 0) mesh.position.y = elevation
group.add(mesh)
}
return group
}
+12 -5
View File
@@ -2,14 +2,21 @@ 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.
* Slab paint on the unified slot model. A slab exposes two faces — `surface`
* (top) and `side` (walls + underside) — each its own mesh tagged with
* `userData.slotId`, so the clicked face resolves to its slot; commit writes
* `node.slots[slotId]` (a shared scene-material or `library:` ref) like the shelf.
*/
export const slabPaint = createSlotPaintCapability({
resolveRole: () => 'surface',
resolveRole: ({ hitObject }) => {
const slotId = (hitObject?.userData as { slotId?: string } | undefined)?.slotId
return slotId === 'side' ? 'side' : 'surface'
},
applyPreview: previewGeometrySlot,
legacyEffective: (node: AnyNode) => {
// Legacy inline material applied to the whole slab → maps onto the top only;
// the side picker shows its own default.
legacyEffective: (node: AnyNode, role: string) => {
if (role !== 'surface') return null
const slab = node as SlabNode
if (slab.materialPreset || slab.material) {
return { material: slab.material, materialPreset: slab.materialPreset }
+19 -7
View File
@@ -1,13 +1,25 @@
import type { SlotDeclaration } from '@pascal-app/core'
export type SlabSlotId = 'surface'
export type SlabSlotId = 'surface' | 'side'
// Declared default appearance for an unpainted slab surface in colored mode —
// a catalog `library:<id>` finish or a `#rrggbb` colour. Textures-off collapses
// to the themed floor role (the escape hatch).
export const SLAB_SLOT_DEFAULT = 'library:wood-woodplank48'
// Declared default appearances for an unpainted slab in colored mode — a
// catalog `library:<id>` finish or a `#rrggbb` colour. Textures-off collapses
// both to the themed floor role (the escape hatch).
//
// `surface` (top face) keeps the wood floor default and the slot id used before
// the top/side split, so existing painted slabs keep their floor finish. `side`
// (walls + underside) defaults to a light grey so a slab's edges read as a
// distinct trim rather than wood end-grain.
export const SLAB_TOP_SLOT_DEFAULT = 'library:wood-woodplank48'
export const SLAB_SIDE_SLOT_DEFAULT = '#cccccc'
/** A slab exposes a single paintable floor surface. */
/**
* A slab exposes two paintable faces: the top floor surface and its sides
* (vertical walls + underside).
*/
export function slabSlots(): SlotDeclaration[] {
return [{ slotId: 'surface', label: 'Surface', default: SLAB_SLOT_DEFAULT }]
return [
{ slotId: 'surface', label: 'Top', default: SLAB_TOP_SLOT_DEFAULT },
{ slotId: 'side', label: 'Sides', default: SLAB_SIDE_SLOT_DEFAULT },
]
}