Add two-sided wall material targeting
This commit is contained in:
@@ -38,6 +38,8 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
|
||||
position: [number, number, number]
|
||||
localPosition: [number, number, number]
|
||||
normal?: [number, number, number]
|
||||
faceIndex?: number
|
||||
materialIndex?: number
|
||||
stopPropagation: () => void
|
||||
nativeEvent: ThreeEvent<PointerEvent>
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<typeof WallNode>
|
||||
|
||||
export type WallSurfaceSide = 'interior' | 'exterior'
|
||||
|
||||
export type WallSurfaceMaterialSpec = {
|
||||
material?: z.infer<typeof MaterialSchema>
|
||||
materialPreset?: string
|
||||
}
|
||||
|
||||
type WallSurfaceMaterialSource = {
|
||||
material?: z.infer<typeof MaterialSchema>
|
||||
materialPreset?: string
|
||||
interiorMaterial?: z.infer<typeof MaterialSchema>
|
||||
interiorMaterialPreset?: string
|
||||
exteriorMaterial?: z.infer<typeof MaterialSchema>
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<AnyNodeId> = 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<WallNode, 'start' | 'end'>,
|
||||
sharedPoint: [number, number],
|
||||
) {
|
||||
function getWallFreeEndpoint(wall: Pick<WallNode, 'start' | 'end'>, 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<AnyNodeId, AnyNode>,
|
||||
): 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({
|
||||
|
||||
@@ -100,6 +100,49 @@ function normalizeStairSegmentNode(node: Record<string, unknown>) {
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||
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<string, any>): Record<string, AnyNode> {
|
||||
const patchedNodes = { ...nodes }
|
||||
for (const [id, node] of Object.entries(patchedNodes)) {
|
||||
@@ -153,6 +196,10 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
patchedNodes[id] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'wall') {
|
||||
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
|
||||
}
|
||||
}
|
||||
return patchedNodes as Record<string, AnyNode>
|
||||
}
|
||||
|
||||
@@ -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<WallNode, 'frontSide' | 'backSide'>,
|
||||
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<number>(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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean>(!!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,6 +199,7 @@ export function MaterialPicker({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!hideSideControl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-16 text-gray-500 text-xs">Side</label>
|
||||
<select
|
||||
@@ -207,6 +214,7 @@ export function MaterialPicker({
|
||||
<option value="double">Double</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<WallNode> {
|
||||
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,15 +236,24 @@ export function WallPanel() {
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Material">
|
||||
{!materialTargetSide ? (
|
||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Click the wall face you want to edit. Materials now apply to one side at a time.
|
||||
</div>
|
||||
) : null}
|
||||
{materialTargetSide ? (
|
||||
<MaterialPicker
|
||||
hideSideControl
|
||||
nodeType="wall"
|
||||
onChange={handleCustomMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={node.materialPreset}
|
||||
value={node.material}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.material}
|
||||
/>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
{!hasWallChildrenBlockingCurve && (
|
||||
@@ -196,6 +264,7 @@ export function WallPanel() {
|
||||
/>
|
||||
)}
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<EditorState>()(
|
||||
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: {},
|
||||
|
||||
@@ -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 (
|
||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||
|
||||
@@ -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<PointerEvent>['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<T extends NodeType>(node: NodeConfig[T]['node'], type: T) {
|
||||
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
|
||||
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`
|
||||
@@ -64,6 +82,8 @@ export function useNodeEvents<T extends NodeType>(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']
|
||||
|
||||
@@ -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<string, WallMaterials>()
|
||||
|
||||
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<Mesh, Material>()
|
||||
const snapshot = new Map<Mesh, Material | Material[]>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<string, WallMaterials>()
|
||||
|
||||
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<Material>()
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user