117 lines
4.0 KiB
TypeScript
117 lines
4.0 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
getEffectiveWallSurfaceMaterial,
|
|
type PaintCapability,
|
|
type PaintPreviewArgs,
|
|
sceneRegistry,
|
|
type WallNode,
|
|
type WallSurfaceSide,
|
|
} from '@pascal-app/core'
|
|
import type { Material, Mesh } from 'three'
|
|
import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint'
|
|
|
|
/**
|
|
* Resolve which side of a wall the user clicked. Walls expose two
|
|
* paintable surfaces — interior + exterior — split by:
|
|
* 1. Material-slot index from the renderer's groups (1 = interior,
|
|
* 2 = exterior). Cheap reference-equality path.
|
|
* 2. Falls back to the hit-surface normal + local-Z when the
|
|
* groups aren't conclusive. Front/back of the wall maps to the
|
|
* node's `frontSide` / `backSide` semantic; absent that, front
|
|
* → interior, back → exterior.
|
|
*
|
|
* Returns null when the click is too oblique (or lands on the wall's
|
|
* end-cap, etc.) to confidently assign a side.
|
|
*/
|
|
export function resolveWallRole(args: {
|
|
node: WallNode
|
|
materialIndex: number | null
|
|
normal: readonly [number, number, number] | undefined
|
|
localPosition: readonly [number, number, number] | undefined
|
|
}): WallSurfaceSide | null {
|
|
const { node, materialIndex, normal, localPosition } = args
|
|
if (materialIndex === 1) return 'interior'
|
|
if (materialIndex === 2) return 'exterior'
|
|
|
|
const normalZ = normal?.[2]
|
|
const localZ = localPosition?.[2]
|
|
const thickness = node.thickness ?? 0.1
|
|
|
|
if (
|
|
normalZ === undefined ||
|
|
localZ === undefined ||
|
|
Math.abs(normalZ) < 0.65 ||
|
|
Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const hitFace = localZ >= 0 ? 'front' : 'back'
|
|
const semantic = hitFace === 'front' ? node.frontSide : node.backSide
|
|
|
|
if (semantic === 'interior' || semantic === 'exterior') {
|
|
return semantic
|
|
}
|
|
|
|
return hitFace === 'front' ? 'interior' : 'exterior'
|
|
}
|
|
|
|
// The wall's 3-material array maps side → group index (see
|
|
// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior.
|
|
const WALL_SIDE_MATERIAL_INDEX: Record<WallSurfaceSide, 1 | 2> = {
|
|
interior: 1,
|
|
exterior: 2,
|
|
}
|
|
|
|
/**
|
|
* Preview a wall paint by swapping just the painted face's entry in the wall
|
|
* mesh's material array. The array is the shared cached `WallMaterials.visible`,
|
|
* so we clone it before swapping and restore the original reference on cleanup
|
|
* (never mutate the cache).
|
|
*/
|
|
function applyWallPreview(args: PaintPreviewArgs): (() => void) | null {
|
|
const { role, material, materialPreset } = args
|
|
const side = role as WallSurfaceSide
|
|
const index = WALL_SIDE_MATERIAL_INDEX[side]
|
|
if (!index) return null
|
|
|
|
const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId)
|
|
if (!(mesh && (mesh as Mesh).isMesh)) return null
|
|
const wallMesh = mesh as Mesh
|
|
|
|
const current = wallMesh.material
|
|
if (!Array.isArray(current)) return null
|
|
|
|
const preview = buildSlotPreviewMaterial(material, materialPreset)
|
|
if (!preview) return () => {}
|
|
|
|
const previous = current as Material[]
|
|
const next = previous.slice()
|
|
next[index] = preview
|
|
wallMesh.material = next
|
|
|
|
return () => {
|
|
wallMesh.material = previous
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Capability binding for the wall kind on the unified slot model. Painting
|
|
* writes `node.slots[interior|exterior]` (a `library:` ref or a minted
|
|
* `scene:` material) exactly like every other kind; `legacyEffective` reads
|
|
* the retired inline `interiorMaterial*` / `exteriorMaterial*` fields so the
|
|
* picker still shows the current value on a pre-migration scene.
|
|
*/
|
|
export const wallPaint: PaintCapability = createSlotPaintCapability({
|
|
roomScope: true,
|
|
resolveRole: ({ node, materialIndex, normal, localPosition }) =>
|
|
resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }),
|
|
applyPreview: applyWallPreview,
|
|
legacyEffective: (node: AnyNode, role: string) => {
|
|
const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide)
|
|
if (spec.material === undefined && spec.materialPreset === undefined) return null
|
|
return { material: spec.material, materialPreset: spec.materialPreset }
|
|
},
|
|
})
|