From 71ddb7052b9e6849136a23d503dc2151e4d9588b Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 11:18:20 +0530 Subject: [PATCH] Add two-sided wall material targeting --- packages/core/src/events/bus.ts | 2 + packages/core/src/schema/index.ts | 23 +- packages/core/src/schema/nodes/wall.ts | 65 +++++ .../core/src/store/actions/node-actions.ts | 50 ++-- packages/core/src/store/use-scene.ts | 47 ++++ .../core/src/systems/wall/wall-system.tsx | 218 ++++++++++++++++- .../components/editor/selection-manager.tsx | 71 ++++++ .../ui/controls/material-picker.tsx | 38 +-- .../src/components/ui/panels/wall-panel.tsx | 113 +++++++-- packages/editor/src/store/use-editor.tsx | 10 + .../renderers/wall/wall-renderer.tsx | 23 +- packages/viewer/src/hooks/use-node-events.ts | 20 ++ .../viewer/src/systems/wall/wall-cutout.tsx | 210 +--------------- .../viewer/src/systems/wall/wall-materials.ts | 226 ++++++++++++++++++ 14 files changed, 824 insertions(+), 292 deletions(-) create mode 100644 packages/viewer/src/systems/wall/wall-materials.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index bb6d2b7b..ca70e049 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -38,6 +38,8 @@ export interface NodeEvent { position: [number, number, number] localPosition: [number, number, number] normal?: [number, number, number] + faceIndex?: number + materialIndex?: number stopPropagation: () => void nativeEvent: ThreeEvent } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 82086641..c1df74ef 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -4,6 +4,13 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base' export { CameraSchema } from './camera' // Collections export { type Collection, type CollectionId, generateCollectionId } from './collections' +export type { + MaterialMapProperties, + MaterialMaps, + MaterialPresetPayload, + MaterialTarget as MaterialTargetValue, + TextureWrapMode as TextureWrapModeValue, +} from './material' // Material export { DEFAULT_MATERIALS, @@ -14,15 +21,8 @@ export { MaterialProperties, MaterialSchema, MaterialTarget, - TextureWrapMode, resolveMaterial, -} from './material' -export type { - MaterialMapProperties, - MaterialMaps, - MaterialPresetPayload, - MaterialTarget as MaterialTargetValue, - TextureWrapMode as TextureWrapModeValue, + TextureWrapMode, } from './material' export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' @@ -58,7 +58,12 @@ export { } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' -export { WallNode } from './nodes/wall' +export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' +export { + getEffectiveWallSurfaceMaterial, + getWallSurfaceMaterialSignature, + WallNode, +} from './nodes/wall' export { WindowNode } from './nodes/window' export { ZoneNode } from './nodes/zone' export type { AnyNodeId, AnyNodeType } from './types' diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index ccbec814..ebce121c 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -12,8 +12,14 @@ export const WallNode = BaseNode.extend({ children: z .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) .default([]), + // Legacy single-material wall finish. Read for backward compatibility only. material: MaterialSchema.optional(), + // Legacy single-material wall finish preset. Read for backward compatibility only. materialPreset: z.string().optional(), + interiorMaterial: MaterialSchema.optional(), + interiorMaterialPreset: z.string().optional(), + exteriorMaterial: MaterialSchema.optional(), + exteriorMaterialPreset: z.string().optional(), thickness: z.number().optional(), height: z.number().optional(), curveOffset: z.number().optional(), @@ -37,3 +43,62 @@ export const WallNode = BaseNode.extend({ `, ) export type WallNode = z.infer + +export type WallSurfaceSide = 'interior' | 'exterior' + +export type WallSurfaceMaterialSpec = { + material?: z.infer + materialPreset?: string +} + +type WallSurfaceMaterialSource = { + material?: z.infer + materialPreset?: string + interiorMaterial?: z.infer + interiorMaterialPreset?: string + exteriorMaterial?: z.infer + exteriorMaterialPreset?: string +} + +function getConfiguredWallSurfaceMaterial( + wall: WallSurfaceMaterialSource, + side: WallSurfaceSide, +): WallSurfaceMaterialSpec { + if (side === 'interior') { + return { + material: wall.interiorMaterial, + materialPreset: wall.interiorMaterialPreset, + } + } + + return { + material: wall.exteriorMaterial, + materialPreset: wall.exteriorMaterialPreset, + } +} + +function hasSurfaceMaterial(spec: WallSurfaceMaterialSpec): boolean { + return spec.material !== undefined || typeof spec.materialPreset === 'string' +} + +export function getEffectiveWallSurfaceMaterial( + wall: WallSurfaceMaterialSource, + side: WallSurfaceSide, +): WallSurfaceMaterialSpec { + const configured = getConfiguredWallSurfaceMaterial(wall, side) + if (hasSurfaceMaterial(configured)) { + return configured + } + + return { + material: wall.material, + materialPreset: wall.materialPreset, + } +} + +export function getWallSurfaceMaterialSignature(spec: WallSurfaceMaterialSpec): string { + return JSON.stringify({ + material: spec.material ?? null, + materialPreset: spec.materialPreset ?? null, + }) +} diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index f6e64a7d..a855871d 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1,4 +1,10 @@ -import type { AnyNode, AnyNodeId, WallNode } from '../../schema' +import { + type AnyNode, + type AnyNodeId, + getEffectiveWallSurfaceMaterial, + getWallSurfaceMaterialSignature, + type WallNode, +} from '../../schema' import type { CollectionId } from '../../schema/collections' import type { SceneState } from '../use-scene' @@ -17,11 +23,7 @@ type WallMergePlan = { let pendingRafId: number | null = null let pendingUpdates: Set = new Set() -function pointsEqual( - a: [number, number], - b: [number, number], - tolerance = 1e-6, -) { +function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) { const dx = a[0] - b[0] const dz = a[1] - b[1] return dx * dx + dz * dz <= tolerance * tolerance @@ -40,32 +42,30 @@ function getWallEndpointAtPoint( return null } -function getWallFreeEndpoint( - wall: Pick, - sharedPoint: [number, number], -) { +function getWallFreeEndpoint(wall: Pick, sharedPoint: [number, number]) { return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start } function areWallStylesCompatible(a: WallNode, b: WallNode) { + const aInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'interior')) + const bInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'interior')) + const aExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'exterior')) + const bExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'exterior')) + return ( (a.parentId ?? null) === (b.parentId ?? null) && Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && - a.materialPreset === b.materialPreset && - JSON.stringify(a.material ?? null) === JSON.stringify(b.material ?? null) && + aInterior === bInterior && + aExterior === bExterior && a.frontSide === b.frontSide && a.backSide === b.backSide && a.visible === b.visible ) } -function areWallsCollinearAcrossPoint( - a: WallNode, - b: WallNode, - sharedPoint: [number, number], -) { +function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) { const freeA = getWallFreeEndpoint(a, sharedPoint) const freeB = getWallFreeEndpoint(b, sharedPoint) const ax = freeA[0] - sharedPoint[0] @@ -111,7 +111,10 @@ function buildMergedWallAttachmentUpdates( mergedEnd: [number, number], nodes: Record, ): WallAttachmentUpdate[] { - const mergedLength = Math.max(Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), 1e-6) + const mergedLength = Math.max( + Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), + 1e-6, + ) const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength const updates: WallAttachmentUpdate[] = [] @@ -126,11 +129,16 @@ function buildMergedWallAttachmentUpdates( const sourceWall = child.parentId === secondary.id ? secondary : primary const sourceLength = Math.max(wallLength(sourceWall), 1e-6) const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 - const worldX = sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength - const worldZ = sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength + const worldX = + sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength + const worldZ = + sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength const nextLocalX = Math.max( 0, - Math.min(mergedLength, (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ), + Math.min( + mergedLength, + (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ, + ), ) updates.push({ diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 543ac30a..a3ba6cf8 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -100,6 +100,49 @@ function normalizeStairSegmentNode(node: Record) { return parsed.success ? parsed.data : null } +function migrateWallSurfaceMaterials(node: Record) { + const hasInterior = + node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string' + const hasExterior = + node.exteriorMaterial !== undefined || typeof node.exteriorMaterialPreset === 'string' + const legacyFinish = { + material: node.material, + materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, + } + + if (!hasInterior && !hasExterior) { + if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { + return node + } + + return { + ...node, + interiorMaterial: legacyFinish.material, + interiorMaterialPreset: legacyFinish.materialPreset, + exteriorMaterial: legacyFinish.material, + exteriorMaterialPreset: legacyFinish.materialPreset, + } + } + + if (!hasInterior) { + return { + ...node, + interiorMaterial: node.exteriorMaterial, + interiorMaterialPreset: node.exteriorMaterialPreset, + } + } + + if (!hasExterior) { + return { + ...node, + exteriorMaterial: node.interiorMaterial, + exteriorMaterialPreset: node.interiorMaterialPreset, + } + } + + return node +} + function migrateNodes(nodes: Record): Record { const patchedNodes = { ...nodes } for (const [id, node] of Object.entries(patchedNodes)) { @@ -153,6 +196,10 @@ function migrateNodes(nodes: Record): Record { patchedNodes[id] = normalized } } + + if (node.type === 'wall') { + patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) + } } return patchedNodes as Record } diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 062eb1f6..5a40c09a 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -7,20 +7,30 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import useScene from '../../store/use-scene' -import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' +import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { calculateLevelMiters, getAdjacentWallIds, getWallMiterBoundaryPoints, type Point2D, - type WallMiterData, pointToKey, + type WallMiterData, } from './wall-mitering' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 +const WALL_FACE_NORMAL_Y_EPSILON = 0.6 +const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003 + +type WallBoundaryEdgeTag = 'front' | 'back' | 'base' + +type TaggedWallBoundaryEdge = { + start: THREE.Vector2 + end: THREE.Vector2 + tag: WallBoundaryEdgeTag +} function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') @@ -78,6 +88,207 @@ function insetCurvedWallBoundaryPointsFor3D( return next } +function addTaggedWallBoundaryEdge( + edges: TaggedWallBoundaryEdge[], + points: { x: number; z: number }[], + startIndex: number, + endIndex: number, + tag: WallBoundaryEdgeTag, +) { + const start = points[startIndex] + const end = points[endIndex] + if (!(start && end)) return + if (Math.hypot(end.x - start.x, end.z - start.z) < 1e-6) return + + edges.push({ + start: new THREE.Vector2(start.x, start.z), + end: new THREE.Vector2(end.x, end.z), + tag, + }) +} + +function buildTaggedWallBoundaryEdges( + wall: WallNode, + localPoints: { x: number; z: number }[], + miterData: WallMiterData, +): TaggedWallBoundaryEdge[] { + if (localPoints.length < 2) return [] + + const edges: TaggedWallBoundaryEdge[] = [] + + if (isCurvedWall(wall)) { + const sidePointCount = Math.floor(localPoints.length / 2) + if (sidePointCount < 2) return edges + + for (let index = 0; index < sidePointCount - 1; index += 1) { + addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'back') + } + + addTaggedWallBoundaryEdge(edges, localPoints, sidePointCount - 1, sidePointCount, 'base') + + for (let index = sidePointCount; index < localPoints.length - 1; index += 1) { + addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'front') + } + + addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base') + return edges + } + + const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] }) + const startJunction = miterData.junctionData.get(startKey)?.get(wall.id) + const startLeftIndex = startJunction ? localPoints.length - 2 : localPoints.length - 1 + const endLeftIndex = startJunction ? localPoints.length - 3 : localPoints.length - 2 + + addTaggedWallBoundaryEdge(edges, localPoints, 0, 1, 'back') + + for (let index = 1; index < endLeftIndex; index += 1) { + addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base') + } + + addTaggedWallBoundaryEdge(edges, localPoints, endLeftIndex, startLeftIndex, 'front') + + for (let index = startLeftIndex; index < localPoints.length - 1; index += 1) { + addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base') + } + + addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base') + + return edges +} + +function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBoundaryEdge): number { + const edgeDx = edge.end.x - edge.start.x + const edgeDz = edge.end.y - edge.start.y + const pointDx = point.x - edge.start.x + const pointDz = point.y - edge.start.y + const edgeLengthSq = edgeDx * edgeDx + edgeDz * edgeDz + + if (edgeLengthSq < 1e-12) { + return point.distanceTo(edge.start) + } + + const t = THREE.MathUtils.clamp((pointDx * edgeDx + pointDz * edgeDz) / edgeLengthSq, 0, 1) + const closestX = edge.start.x + edgeDx * t + const closestZ = edge.start.y + edgeDz * t + + return Math.hypot(point.x - closestX, point.y - closestZ) +} + +function getWallFaceMaterialIndex( + wall: Pick, + face: 'front' | 'back', +): 0 | 1 | 2 { + const semantic = face === 'front' ? wall.frontSide : wall.backSide + const fallback = face === 'front' ? 1 : 2 + + if (semantic === 'interior') return 1 + if (semantic === 'exterior') return 2 + return fallback +} + +function assignWallMaterialGroups( + geometry: THREE.BufferGeometry, + wall: WallNode, + boundaryEdges: TaggedWallBoundaryEdge[], +) { + const position = geometry.getAttribute('position') + if (!position) return + + const index = geometry.getIndex() + const triangleCount = index ? Math.floor(index.count / 3) : Math.floor(position.count / 3) + if (triangleCount === 0) { + geometry.clearGroups() + return + } + + const triangleMaterials = new Array(triangleCount).fill(0) + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + const normal = new THREE.Vector3() + const centroid = new THREE.Vector3() + const projectedCentroid = new THREE.Vector2() + const maxBoundaryDistance = Math.max( + getWallThickness(wall) * 0.02, + WALL_FACE_EDGE_DISTANCE_EPSILON, + ) + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + const baseIndex = triangleIndex * 3 + const ia = index ? index.getX(baseIndex) : baseIndex + const ib = index ? index.getX(baseIndex + 1) : baseIndex + 1 + const ic = index ? index.getX(baseIndex + 2) : baseIndex + 2 + + a.fromBufferAttribute(position, ia) + b.fromBufferAttribute(position, ib) + c.fromBufferAttribute(position, ic) + + ab.subVectors(b, a) + ac.subVectors(c, a) + normal.crossVectors(ab, ac) + + if (normal.lengthSq() < 1e-12) { + triangleMaterials[triangleIndex] = 0 + continue + } + + normal.normalize() + + if (Math.abs(normal.y) >= WALL_FACE_NORMAL_Y_EPSILON) { + triangleMaterials[triangleIndex] = 0 + continue + } + + centroid + .copy(a) + .add(b) + .add(c) + .multiplyScalar(1 / 3) + projectedCentroid.set(centroid.x, centroid.z) + + let nearestTag: WallBoundaryEdgeTag | null = null + let nearestDistance = Number.POSITIVE_INFINITY + + for (const edge of boundaryEdges) { + const distance = distanceToWallBoundaryEdge(projectedCentroid, edge) + if (distance < nearestDistance) { + nearestDistance = distance + nearestTag = edge.tag + } + } + + if (!nearestTag || nearestDistance > maxBoundaryDistance) { + triangleMaterials[triangleIndex] = 0 + continue + } + + if (nearestTag === 'base') { + triangleMaterials[triangleIndex] = 0 + continue + } + + triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag) + } + + geometry.clearGroups() + + let currentMaterial = triangleMaterials[0] ?? 0 + let groupStart = 0 + + for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) { + const materialIndex = triangleMaterials[triangleIndex] ?? 0 + if (materialIndex === currentMaterial) continue + + geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) + groupStart = triangleIndex + currentMaterial = materialIndex + } + + geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) +} + // ============================================================================ // WALL SYSTEM // ============================================================================ @@ -252,6 +463,7 @@ export function generateExtrudedWall( // Convert polygon to local coordinates const localPoints = polyPoints.map(worldToLocal) + const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData) // Build THREE.js shape // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z @@ -272,6 +484,7 @@ export function generateExtrudedWall( // Rotate so extrusion direction (Z) becomes height direction (Y) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() + assignWallMaterialGroups(geometry, wallNode, boundaryEdges) ensureUv2Attribute(geometry) // Apply CSG subtraction for cutouts (doors/windows) @@ -307,6 +520,7 @@ export function generateExtrudedWall( const resultGeometry = resultBrush.geometry resultGeometry.computeVertexNormals() + assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges) ensureUv2Attribute(resultGeometry) return resultGeometry diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 3d9ef80a..e9cfcea4 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -8,6 +8,8 @@ import { resolveLevelId, sceneRegistry, useScene, + type WallEvent, + type WallSurfaceSide, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -68,6 +70,32 @@ export const resolveBuildingId = ( return null } +function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null { + if (event.materialIndex === 1) return 'interior' + if (event.materialIndex === 2) return 'exterior' + + const normalZ = event.normal?.[2] + const localZ = event.localPosition[2] + const thickness = event.node.thickness ?? 0.1 + + if ( + normalZ === 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' ? event.node.frontSide : event.node.backSide + + if (semantic === 'interior' || semantic === 'exterior') { + return semantic + } + + return hitFace === 'front' ? 'interior' : 'exterior' +} + const HIGHLIGHT_PROFILES = { delete: { color: new Color('#dc2626'), @@ -439,6 +467,23 @@ export const SelectionManager = () => { activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current) + if (node.type === 'wall' && nodeToSelect.type === 'wall') { + const nextWallMaterialTarget = resolveWallMaterialTarget(event as WallEvent) + if (nextWallMaterialTarget) { + useEditor.getState().setSelectedWallMaterialTarget({ + wallId: nodeToSelect.id, + side: nextWallMaterialTarget, + }) + } else { + const currentWallMaterialTarget = useEditor.getState().selectedWallMaterialTarget + if (currentWallMaterialTarget?.wallId !== nodeToSelect.id) { + useEditor.getState().setSelectedWallMaterialTarget(null) + } + } + } else if (useEditor.getState().selectedWallMaterialTarget) { + useEditor.getState().setSelectedWallMaterialTarget(null) + } + // Reset the handled flag after a short delay to allow grid:click to be ignored setTimeout(() => { clickHandledRef.current = false @@ -471,6 +516,7 @@ export const SelectionManager = () => { const { phase, structureLayer } = useEditor.getState() const activeStrategy = SELECTION_STRATEGIES[phase] if (activeStrategy) activeStrategy.handleDeselect() + useEditor.getState().setSelectedWallMaterialTarget(null) // When deselecting from zone mode, return to structure select if (phase === 'structure' && structureLayer === 'zones') { @@ -704,6 +750,12 @@ export const SelectionManager = () => { } const SelectionStateSync = () => { + const selectedWallMaterialTarget = useEditor((s) => s.selectedWallMaterialTarget) + const setSelectedWallMaterialTarget = useEditor((s) => s.setSelectedWallMaterialTarget) + const singleSelectedId = useViewer((s) => + s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null, + ) + useEffect(() => { return useScene.subscribe((state) => { const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection @@ -732,6 +784,25 @@ const SelectionStateSync = () => { }) }, []) + useEffect(() => { + if (!selectedWallMaterialTarget) return + + if (!singleSelectedId) { + setSelectedWallMaterialTarget(null) + return + } + + const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId] + if (!(selectedNode?.type === 'wall')) { + setSelectedWallMaterialTarget(null) + return + } + + if (selectedWallMaterialTarget.wallId !== selectedNode.id) { + setSelectedWallMaterialTarget(null) + } + }, [selectedWallMaterialTarget, setSelectedWallMaterialTarget, singleSelectedId]) + return null } diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index ec74cbf1..0974ea50 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -6,7 +6,7 @@ import { type MaterialSchema, type MaterialTarget, } from '@pascal-app/core' -import { useState } from 'react' +import { useEffect, useState } from 'react' type MaterialPickerProps = { nodeType?: MaterialTarget @@ -14,6 +14,7 @@ type MaterialPickerProps = { selectedMaterialPreset?: string onChange?: (material: MaterialSchema) => void onSelectMaterialPreset?: (materialPreset: string) => void + hideSideControl?: boolean } export function MaterialPicker({ @@ -22,10 +23,15 @@ export function MaterialPicker({ selectedMaterialPreset, onChange, onSelectMaterialPreset, + hideSideControl = false, }: MaterialPickerProps) { const [showCustom, setShowCustom] = useState(!!value?.properties) const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : [] + useEffect(() => { + setShowCustom(!!value?.properties && !selectedMaterialPreset) + }, [selectedMaterialPreset, value?.properties]) + const currentProps = value?.properties || { color: '#ffffff', roughness: 0.5, @@ -193,20 +199,22 @@ export function MaterialPicker({ -
- - -
+ {!hideSideControl && ( +
+ + +
+ )} )} diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 066a9059..96cd697b 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -3,17 +3,20 @@ import { type AnyNode, type AnyNodeId, + getEffectiveWallSurfaceMaterial, getClampedWallCurveOffset, getMaxWallCurveOffset, getWallCurveLength, + getWallSurfaceMaterialSignature, normalizeWallCurveOffset, type MaterialSchema, useScene, + type WallSurfaceSide, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Move, Spline } from 'lucide-react' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -22,12 +25,39 @@ import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' +function buildWallSurfaceMaterialPatch( + node: WallNode, + targetSide: WallSurfaceSide | null, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const nextSurfaceMaterial = { material, materialPreset } + const nextInterior = + targetSide === null || targetSide === 'interior' + ? nextSurfaceMaterial + : getEffectiveWallSurfaceMaterial(node, 'interior') + const nextExterior = + targetSide === null || targetSide === 'exterior' + ? nextSurfaceMaterial + : getEffectiveWallSurfaceMaterial(node, 'exterior') + + return { + interiorMaterial: nextInterior.material, + interiorMaterialPreset: nextInterior.materialPreset, + exteriorMaterial: nextExterior.material, + exteriorMaterialPreset: nextExterior.materialPreset, + material: undefined, + materialPreset: undefined, + } +} + export function WallPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const setMovingNode = useEditor((s) => s.setMovingNode) const setCurvingWall = useEditor((s) => s.setCurvingWall) + const selectedWallMaterialTarget = useEditor((s) => s.selectedWallMaterialTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, @@ -58,6 +88,33 @@ export function WallPanel() { [selectedId, updateNode], ) + const effectiveInteriorMaterial = useMemo( + () => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}), + [node], + ) + const effectiveExteriorMaterial = useMemo( + () => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}), + [node], + ) + const surfaceMaterialsMatch = useMemo( + () => + getWallSurfaceMaterialSignature(effectiveInteriorMaterial) === + getWallSurfaceMaterialSignature(effectiveExteriorMaterial), + [effectiveExteriorMaterial, effectiveInteriorMaterial], + ) + const materialTargetSide = + selectedWallMaterialTarget && selectedWallMaterialTarget.wallId === node?.id + ? selectedWallMaterialTarget.side + : null + const materialPickerValue = + materialTargetSide === 'interior' + ? effectiveInteriorMaterial + : materialTargetSide === 'exterior' + ? effectiveExteriorMaterial + : surfaceMaterialsMatch + ? effectiveInteriorMaterial + : {} + const handleUpdateLength = useCallback( (newLength: number) => { if (!node || newLength <= 0) return @@ -83,16 +140,18 @@ export function WallPanel() { const handleMaterialPresetChange = useCallback( (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) + if (!node || !materialTargetSide) return + handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset)) }, - [handleUpdate], + [handleUpdate, materialTargetSide, node], ) const handleCustomMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) + if (!node || !materialTargetSide) return + handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined)) }, - [handleUpdate], + [handleUpdate, materialTargetSide, node], ) const handleClose = useCallback(() => { @@ -177,25 +236,35 @@ export function WallPanel() { - + {!materialTargetSide ? ( +
+ Click the wall face you want to edit. Materials now apply to one side at a time. +
+ ) : null} + {materialTargetSide ? ( + + ) : null}
- - } label="Move" onClick={handleMove} /> - {!hasWallChildrenBlockingCurve && ( - } - label="Curve" - onClick={handleCurve} - /> - )} - + + + } label="Move" onClick={handleMove} /> + {!hasWallChildrenBlockingCurve && ( + } + label="Curve" + onClick={handleCurve} + /> + )} + + ) } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 6fa9bb4b..c5e5e833 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -16,6 +16,7 @@ import { type StairSegmentNode, useScene, type WallNode, + type WallSurfaceSide, type WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -80,6 +81,11 @@ export type MovingWallEndpoint = { endpoint: 'start' | 'end' } +export type SelectedWallMaterialTarget = { + wallId: WallNode['id'] + side: WallSurfaceSide +} + type EditorState = { phase: Phase setPhase: (phase: Phase) => void @@ -127,6 +133,8 @@ type EditorState = { setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void curvingWall: WallNode | null setCurvingWall: (wall: WallNode | null) => void + selectedWallMaterialTarget: SelectedWallMaterialTarget | null + setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -502,6 +510,8 @@ const useEditor = create()( setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }), curvingWall: null, setCurvingWall: (wall) => set({ curvingWall: wall }), + selectedWallMaterialTarget: null, + setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index ef6f70cf..9ff38006 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -1,12 +1,8 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core' -import { useLayoutEffect, useMemo, useRef } from 'react' +import { useLayoutEffect, useRef } from 'react' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { - createMaterial, - createMaterialFromPresetRef, - DEFAULT_WALL_MATERIAL, -} from '../../../lib/materials' +import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials' import { NodeRenderer } from '../node-renderer' export const WallRenderer = ({ node }: { node: WallNode }) => { @@ -19,20 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'wall') - - const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) - if (presetMaterial) return presetMaterial - const mat = node.material - if (!mat) return DEFAULT_WALL_MATERIAL - return createMaterial(mat) - }, [ - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - node.materialPreset, - ]) + const material = getVisibleWallMaterials(node) return ( diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index d247ece1..cc3eae2f 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -33,6 +33,7 @@ import { type ZoneNode, } from '@pascal-app/core' import type { ThreeEvent } from '@react-three/fiber' +import type { BufferGeometry, Mesh } from 'three' import useViewer from '../store/use-viewer' type NodeConfig = { @@ -55,6 +56,23 @@ type NodeConfig = { type NodeType = keyof NodeConfig +function getIntersectionMaterialIndex( + object: ThreeEvent['object'], + faceIndex: number | undefined, +): number | undefined { + if (faceIndex === undefined) return undefined + + const geometry = (object as Mesh).geometry as BufferGeometry | undefined + if (!geometry || geometry.groups.length === 0) return undefined + + const triangleStart = faceIndex * 3 + const group = geometry.groups.find( + (entry) => triangleStart >= entry.start && triangleStart < entry.start + entry.count, + ) + + return group?.materialIndex +} + export function useNodeEvents(node: NodeConfig[T]['node'], type: T) { const emit = (suffix: EventSuffix, e: ThreeEvent) => { const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}` @@ -64,6 +82,8 @@ export function useNodeEvents(node: NodeConfig[T]['node'], t position: [e.point.x, e.point.y, e.point.z], localPosition: [localPoint.x, localPoint.y, localPoint.z], normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined, + faceIndex: e.faceIndex ?? undefined, + materialIndex: getIntersectionMaterialIndex(e.object, e.faceIndex ?? undefined), stopPropagation: () => e.stopPropagation(), nativeEvent: e, } as NodeConfig[T]['event'] diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index b80613d1..cec20d01 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -1,210 +1,14 @@ -import { - type AnyNodeId, - baseMaterial, - emitter, - getMaterialPresetByRef, - sceneRegistry, - useScene, - type WallNode, -} from '@pascal-app/core' +import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import type { Material } from 'three' -import { Color } from 'three' -import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' -import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' +import { type Mesh, Vector3 } from 'three/webgpu' import useViewer from '../../store/use-viewer' -import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' +import { getMaterialsForWall } from './wall-materials' const tmpVec = new Vector3() const u = new Vector3() const v = new Vector3() -const DEFAULT_WALL_COLOR = '#f2f0ed' -const WALL_HIGHLIGHT_PROFILES = { - delete: { - color: new Color('#dc2626'), - blend: 0.76, - emissiveBlend: 0.92, - emissiveIntensity: 0.46, - }, - selection: { - color: new Color('#818cf8'), - blend: 0.32, - emissiveBlend: 0.7, - emissiveIntensity: 0.42, - }, -} as const - -type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES - -const dotPattern = Fn(() => { - const scale = float(0.1) - const dotSize = float(0.3) - - const uv = vec2(positionLocal.x, positionLocal.y).div(scale) - const gridUV = fract(uv) - - const dist = length(gridUV.sub(0.5)) - - const dots = step(dist, dotSize.mul(0.5)) - - const fadeHeight = float(2.5) - const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y)) - - return dots.mul(yFade) -}) - -interface WallMaterials { - visible: Material - invisible: MeshStandardNodeMaterial - deleteVisible: Material - deleteInvisible: MeshStandardNodeMaterial - highlightedVisible: Material - highlightedInvisible: MeshStandardNodeMaterial - materialHash: string -} - -const wallMaterialCache = new Map() - -const presetColors = { - white: '#ffffff', - brick: '#8b4513', - concrete: '#808080', - wood: '#deb887', - glass: '#87ceeb', - metal: '#c0c0c0', - plaster: '#f5f5dc', - tile: '#dcdcdc', - marble: '#f5f5f5', -} as const - -function getMaterialHash(wallNode: WallNode): string { - if (wallNode.materialPreset) return `preset-ref-${wallNode.materialPreset}` - if (!wallNode.material) return 'none' - const mat = wallNode.material - if (mat.preset && mat.preset !== 'custom') { - return `preset-${mat.preset}` - } - if (mat.properties) { - return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}` - } - return 'default' -} - -function getPresetColor(preset: string): string { - return presetColors[preset as keyof typeof presetColors] ?? '#ffffff' -} - -function getHighlightedColor(color: Color, kind: WallHighlightKind): Color { - const profile = WALL_HIGHLIGHT_PROFILES[kind] - return color.clone().lerp(profile.color, profile.blend) -} - -function createHighlightedWallMaterial( - material: Material, - kind: WallHighlightKind, -): Material { - const highlightedMaterial = material.clone() as Material & { - color?: Color - emissive?: Color - emissiveIntensity?: number - needsUpdate?: boolean - } - const profile = WALL_HIGHLIGHT_PROFILES[kind] - - if ('color' in highlightedMaterial && highlightedMaterial.color) { - highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind) - } - if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) { - highlightedMaterial.emissive = highlightedMaterial.emissive - .clone() - .lerp(profile.color, profile.emissiveBlend) - } - if ('emissiveIntensity' in highlightedMaterial) { - highlightedMaterial.emissiveIntensity = Math.max( - highlightedMaterial.emissiveIntensity ?? 0, - profile.emissiveIntensity, - ) - } - highlightedMaterial.needsUpdate = true - - return highlightedMaterial -} - -function createBaseVisibleWallMaterial(wallNode: WallNode): Material { - if (wallNode.materialPreset) { - return createMaterialFromPresetRef(wallNode.materialPreset) ?? baseMaterial - } - - if (wallNode.material) { - return createMaterial(wallNode.material) - } - - return baseMaterial -} - -function getMaterialsForWall(wallNode: WallNode): WallMaterials { - const cacheKey = wallNode.id - const materialHash = getMaterialHash(wallNode) - - const existing = wallMaterialCache.get(cacheKey) - if (existing && existing.materialHash === materialHash) { - return existing - } - - if (existing) { - existing.visible.dispose() - existing.invisible.dispose() - existing.deleteVisible.dispose() - existing.deleteInvisible.dispose() - existing.highlightedVisible.dispose() - existing.highlightedInvisible.dispose() - } - - let userColor = DEFAULT_WALL_COLOR - const preset = getMaterialPresetByRef(wallNode.materialPreset) - if (preset?.mapProperties?.color) { - userColor = preset.mapProperties.color - } else if (wallNode.material?.properties?.color) { - userColor = wallNode.material.properties.color - } else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') { - userColor = getPresetColor(wallNode.material.preset) - } - - const visibleMat = createBaseVisibleWallMaterial(wallNode) - - const invisibleMat = new MeshStandardNodeMaterial({ - transparent: true, - opacityNode: mix(float(0.0), float(0.24), dotPattern()), - color: userColor, - depthWrite: false, - emissive: userColor, - }) - - const highlightedVisible = createHighlightedWallMaterial(visibleMat, 'selection') - const highlightedInvisible = createHighlightedWallMaterial( - invisibleMat, - 'selection', - ) as MeshStandardNodeMaterial - const deleteVisible = createHighlightedWallMaterial(visibleMat, 'delete') - const deleteInvisible = createHighlightedWallMaterial(invisibleMat, 'delete') as MeshStandardNodeMaterial - - const result: WallMaterials = { - visible: visibleMat, - invisible: invisibleMat, - deleteVisible, - deleteInvisible, - highlightedVisible, - highlightedInvisible, - materialHash, - } - wallMaterialCache.set(cacheKey, result) - return result -} - -function getVisibleWallMaterial(wallNode: WallNode): Material { - return createBaseVisibleWallMaterial(wallNode) -} function getWallHideState( wallNode: WallNode, @@ -301,7 +105,7 @@ export const WallCutout = () => { ? materials.deleteVisible : isSelectionHighlighted ? materials.highlightedVisible - : getVisibleWallMaterial(wallNode) + : materials.visible } }) lastWallMode.current = wallMode @@ -311,7 +115,7 @@ export const WallCutout = () => { }) useEffect(() => { - const snapshot = new Map() + const snapshot = new Map() const restoreForCapture = () => { sceneRegistry.byType.wall.forEach((wallId) => { @@ -320,10 +124,10 @@ export const WallCutout = () => { const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined if (!wallNode || wallNode.type !== 'wall') return const mats = getMaterialsForWall(wallNode) - const current = wallMesh.material as Material + const current = wallMesh.material as Material | Material[] snapshot.set(wallMesh, current) if (current === mats.highlightedVisible || current === mats.deleteVisible) { - wallMesh.material = getVisibleWallMaterial(wallNode) + wallMesh.material = mats.visible } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { wallMesh.material = mats.invisible } diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts new file mode 100644 index 00000000..5edccfea --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -0,0 +1,226 @@ +import { + baseMaterial, + getEffectiveWallSurfaceMaterial, + getMaterialPresetByRef, + getWallSurfaceMaterialSignature, + resolveMaterial, + type WallNode, + type WallSurfaceMaterialSpec, +} from '@pascal-app/core' +import { Color, type Material } from 'three' +import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' +import { MeshStandardNodeMaterial } from 'three/webgpu' +import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' + +const DEFAULT_WALL_COLOR = '#f2f0ed' + +const WALL_HIGHLIGHT_PROFILES = { + delete: { + color: new Color('#dc2626'), + blend: 0.76, + emissiveBlend: 0.92, + emissiveIntensity: 0.46, + }, + selection: { + color: new Color('#818cf8'), + blend: 0.32, + emissiveBlend: 0.7, + emissiveIntensity: 0.42, + }, +} as const + +type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES + +export type WallMaterialArray = [Material, Material, Material] + +export interface WallMaterials { + visible: WallMaterialArray + invisible: WallMaterialArray + deleteVisible: WallMaterialArray + deleteInvisible: WallMaterialArray + highlightedVisible: WallMaterialArray + highlightedInvisible: WallMaterialArray + materialHash: string +} + +const wallMaterialCache = new Map() + +const dotPattern = Fn(() => { + const scale = float(0.1) + const dotSize = float(0.3) + + const uv = vec2(positionLocal.x, positionLocal.y).div(scale) + const gridUV = fract(uv) + + const dist = length(gridUV.sub(0.5)) + + const dots = step(dist, dotSize.mul(0.5)) + + const fadeHeight = float(2.5) + const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y)) + + return dots.mul(yFade) +}) + +function getSurfaceVisibleMaterial(spec: WallSurfaceMaterialSpec): Material { + if (spec.materialPreset) { + return createMaterialFromPresetRef(spec.materialPreset) ?? baseMaterial + } + + if (spec.material) { + return createMaterial(spec.material) + } + + return baseMaterial +} + +function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string { + const preset = getMaterialPresetByRef(spec.materialPreset) + if (preset?.mapProperties?.color) { + return preset.mapProperties.color + } + + if (spec.material) { + return resolveMaterial(spec.material).color + } + + return fallback +} + +function getHighlightedColor(color: Color, kind: WallHighlightKind): Color { + const profile = WALL_HIGHLIGHT_PROFILES[kind] + return color.clone().lerp(profile.color, profile.blend) +} + +function createHighlightedWallMaterial(material: Material, kind: WallHighlightKind): Material { + const highlightedMaterial = material.clone() as Material & { + color?: Color + emissive?: Color + emissiveIntensity?: number + needsUpdate?: boolean + } + const profile = WALL_HIGHLIGHT_PROFILES[kind] + + if ('color' in highlightedMaterial && highlightedMaterial.color) { + highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind) + } + if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) { + highlightedMaterial.emissive = highlightedMaterial.emissive + .clone() + .lerp(profile.color, profile.emissiveBlend) + } + if ('emissiveIntensity' in highlightedMaterial) { + highlightedMaterial.emissiveIntensity = Math.max( + highlightedMaterial.emissiveIntensity ?? 0, + profile.emissiveIntensity, + ) + } + highlightedMaterial.needsUpdate = true + + return highlightedMaterial +} + +function createInvisibleWallMaterial(color: string): MeshStandardNodeMaterial { + return new MeshStandardNodeMaterial({ + transparent: true, + opacityNode: mix(float(0.0), float(0.24), dotPattern()), + color, + depthWrite: false, + emissive: color, + }) +} + +function mapWallMaterialArray( + materials: WallMaterialArray, + iteratee: (material: Material, index: number) => Material, +): WallMaterialArray { + return materials.map(iteratee) as WallMaterialArray +} + +function disposeOwnedMaterials(materials: WallMaterialArray[]) { + const owned = new Set() + materials.forEach((entry) => { + entry.forEach((material) => { + owned.add(material) + }) + }) + owned.forEach((material) => { + material.dispose() + }) +} + +export function getWallMaterialHash(wallNode: WallNode): string { + return JSON.stringify({ + interior: getWallSurfaceMaterialSignature( + getEffectiveWallSurfaceMaterial(wallNode, 'interior'), + ), + exterior: getWallSurfaceMaterialSignature( + getEffectiveWallSurfaceMaterial(wallNode, 'exterior'), + ), + }) +} + +export function getMaterialsForWall(wallNode: WallNode): WallMaterials { + const cacheKey = wallNode.id + const materialHash = getWallMaterialHash(wallNode) + + const existing = wallMaterialCache.get(cacheKey) + if (existing && existing.materialHash === materialHash) { + return existing + } + + if (existing) { + disposeOwnedMaterials([ + existing.invisible, + existing.deleteVisible, + existing.deleteInvisible, + existing.highlightedVisible, + existing.highlightedInvisible, + ]) + } + + const interiorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'interior') + const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior') + + const visible: WallMaterialArray = [ + baseMaterial, + getSurfaceVisibleMaterial(interiorSpec), + getSurfaceVisibleMaterial(exteriorSpec), + ] + + const invisible: WallMaterialArray = [ + createInvisibleWallMaterial(DEFAULT_WALL_COLOR), + createInvisibleWallMaterial(getSurfaceColor(interiorSpec, DEFAULT_WALL_COLOR)), + createInvisibleWallMaterial(getSurfaceColor(exteriorSpec, DEFAULT_WALL_COLOR)), + ] + + const highlightedVisible = mapWallMaterialArray(visible, (material) => + createHighlightedWallMaterial(material, 'selection'), + ) + const highlightedInvisible = mapWallMaterialArray(invisible, (material) => + createHighlightedWallMaterial(material, 'selection'), + ) + const deleteVisible = mapWallMaterialArray(visible, (material) => + createHighlightedWallMaterial(material, 'delete'), + ) + const deleteInvisible = mapWallMaterialArray(invisible, (material) => + createHighlightedWallMaterial(material, 'delete'), + ) + + const result: WallMaterials = { + visible, + invisible, + deleteVisible, + deleteInvisible, + highlightedVisible, + highlightedInvisible, + materialHash, + } + + wallMaterialCache.set(cacheKey, result) + return result +} + +export function getVisibleWallMaterials(wallNode: WallNode): WallMaterialArray { + return getMaterialsForWall(wallNode).visible +}