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,
|
resolveMaterialRef,
|
||||||
resolveSlotDefaultMaterial,
|
resolveSlotDefaultMaterial,
|
||||||
} from '@pascal-app/viewer'
|
} from '@pascal-app/viewer'
|
||||||
import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
|
import {
|
||||||
import { SLAB_SLOT_DEFAULT } from './slots'
|
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
|
* Stage B builder for slab. Reuses `generateSlabGeometry` (pure
|
||||||
@@ -33,38 +42,92 @@ type SlabMaterial = Material & {
|
|||||||
|
|
||||||
const slabMaterialCache = new Map<string, Material>()
|
const slabMaterialCache = new Map<string, Material>()
|
||||||
|
|
||||||
function getSlabMaterial(
|
function getSlabSlotMaterial(
|
||||||
node: SlabNode,
|
node: SlabNode,
|
||||||
|
slotId: SlabSlotId,
|
||||||
shading: RenderShading,
|
shading: RenderShading,
|
||||||
textures: boolean,
|
textures: boolean,
|
||||||
colorPreset: ColorPreset,
|
colorPreset: ColorPreset,
|
||||||
sceneTheme: string | undefined,
|
sceneTheme: string | undefined,
|
||||||
sceneMaterials: GeometryContext['materials'],
|
sceneMaterials: GeometryContext['materials'],
|
||||||
): Material {
|
): Material {
|
||||||
// Textures-off mode takes the themed 'floor' role colour — the guaranteed
|
// Textures-off mode takes the themed 'floor' role colour for every face — the
|
||||||
// escape hatch, independent of any slot override. createSurfaceRoleMaterial
|
// guaranteed escape hatch, independent of any slot override. FrontSide —
|
||||||
// returns a shared cached material. FrontSide — DoubleSide on the role
|
// DoubleSide on the role material's NodeMaterial poisons the MRT scene pass
|
||||||
// material's NodeMaterial poisons the MRT scene pass (see `materials.ts`
|
// (see `materials.ts` line 77 / glazing fix 9400f1c5). Slab side faces still
|
||||||
// line 77 / glazing fix 9400f1c5). Slab side faces still render correctly
|
// render correctly because `generateSlabGeometry` emits outward-facing normals.
|
||||||
// because `generateSlabGeometry` produces outward-facing normals.
|
|
||||||
if (!textures) {
|
if (!textures) {
|
||||||
return createSurfaceRoleMaterial('floor', colorPreset, FrontSide, sceneTheme)
|
return createSurfaceRoleMaterial('floor', colorPreset, FrontSide, sceneTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified slot override — shared scene material or catalog `library:` finish.
|
// Unified slot override — shared scene material or catalog `library:` finish.
|
||||||
const slotRef = node.slots?.surface
|
const slotRef = node.slots?.[slotId]
|
||||||
if (slotRef) {
|
if (slotRef) {
|
||||||
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
|
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
|
||||||
if (resolved) return resolved
|
if (resolved) return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy inline material / preset, for scenes painted before the slot model.
|
// Legacy inline material / preset (pre-slot-model scenes) applied to the whole
|
||||||
if (node.materialPreset || node.material) {
|
// slab — map it onto the top face only; sides take their own default.
|
||||||
|
if (slotId === 'surface' && (node.materialPreset || node.material)) {
|
||||||
return getLegacySlabMaterial(node, shading)
|
return getLegacySlabMaterial(node, shading)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Declared slot default — a catalog `library:` finish or a flat colour.
|
// 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 {
|
function getLegacySlabMaterial(node: SlabNode, shading: RenderShading): Material {
|
||||||
@@ -114,15 +177,32 @@ export function buildSlabGeometry(
|
|||||||
sceneTheme?: string,
|
sceneTheme?: string,
|
||||||
): Group {
|
): Group {
|
||||||
const group = new Group()
|
const group = new Group()
|
||||||
const geometry = generateSlabGeometry(node)
|
const merged = generateSlabGeometry(node)
|
||||||
const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme, ctx?.materials)
|
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)
|
const mesh = new Mesh(geometry, material)
|
||||||
mesh.castShadow = true
|
mesh.castShadow = true
|
||||||
mesh.receiveShadow = true
|
mesh.receiveShadow = true
|
||||||
// Tag the surface so the unified slot paint can resolve the hit and preview.
|
mesh.userData.slotId = slotId
|
||||||
mesh.userData.slotId = 'surface'
|
|
||||||
const elevation = node.elevation ?? 0.05
|
|
||||||
if (elevation < 0) mesh.position.y = elevation
|
if (elevation < 0) mesh.position.y = elevation
|
||||||
group.add(mesh)
|
group.add(mesh)
|
||||||
|
}
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ import type { AnyNode, SlabNode } from '@pascal-app/core'
|
|||||||
import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint'
|
import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slab paint on the unified slot model. A slab has one paintable surface, so
|
* Slab paint on the unified slot model. A slab exposes two faces — `surface`
|
||||||
* every face resolves to the `surface` slot; commit writes `node.slots.surface`
|
* (top) and `side` (walls + underside) — each its own mesh tagged with
|
||||||
* (a shared scene-material or `library:` ref) like the shelf.
|
* `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({
|
export const slabPaint = createSlotPaintCapability({
|
||||||
resolveRole: () => 'surface',
|
resolveRole: ({ hitObject }) => {
|
||||||
|
const slotId = (hitObject?.userData as { slotId?: string } | undefined)?.slotId
|
||||||
|
return slotId === 'side' ? 'side' : 'surface'
|
||||||
|
},
|
||||||
applyPreview: previewGeometrySlot,
|
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
|
const slab = node as SlabNode
|
||||||
if (slab.materialPreset || slab.material) {
|
if (slab.materialPreset || slab.material) {
|
||||||
return { material: slab.material, materialPreset: slab.materialPreset }
|
return { material: slab.material, materialPreset: slab.materialPreset }
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import type { SlotDeclaration } from '@pascal-app/core'
|
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 —
|
// Declared default appearances for an unpainted slab in colored mode — a
|
||||||
// a catalog `library:<id>` finish or a `#rrggbb` colour. Textures-off collapses
|
// catalog `library:<id>` finish or a `#rrggbb` colour. Textures-off collapses
|
||||||
// to the themed floor role (the escape hatch).
|
// both to the themed floor role (the escape hatch).
|
||||||
export const SLAB_SLOT_DEFAULT = 'library:wood-woodplank48'
|
//
|
||||||
|
// `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[] {
|
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