diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 851660ec..6b3c241d 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -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() -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 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 merged = generateSlabGeometry(node) + const { top, side } = splitSlabFacesByFacing(merged) + merged.dispose() + const elevation = node.elevation ?? 0.05 - if (elevation < 0) mesh.position.y = elevation - group.add(mesh) + // 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 + mesh.userData.slotId = slotId + if (elevation < 0) mesh.position.y = elevation + group.add(mesh) + } return group } diff --git a/packages/nodes/src/slab/paint.ts b/packages/nodes/src/slab/paint.ts index a5387849..628c75cc 100644 --- a/packages/nodes/src/slab/paint.ts +++ b/packages/nodes/src/slab/paint.ts @@ -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 } diff --git a/packages/nodes/src/slab/slots.ts b/packages/nodes/src/slab/slots.ts index 094bdb0c..bdb2c808 100644 --- a/packages/nodes/src/slab/slots.ts +++ b/packages/nodes/src/slab/slots.ts @@ -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:` 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:` 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 }, + ] }