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:
co-authored by
Claude Opus 4.8
parent
6b67ab6949
commit
d924931cee
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user