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]
|
position: [number, number, number]
|
||||||
localPosition: [number, number, number]
|
localPosition: [number, number, number]
|
||||||
normal?: [number, number, number]
|
normal?: [number, number, number]
|
||||||
|
faceIndex?: number
|
||||||
|
materialIndex?: number
|
||||||
stopPropagation: () => void
|
stopPropagation: () => void
|
||||||
nativeEvent: ThreeEvent<PointerEvent>
|
nativeEvent: ThreeEvent<PointerEvent>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
|||||||
export { CameraSchema } from './camera'
|
export { CameraSchema } from './camera'
|
||||||
// Collections
|
// Collections
|
||||||
export { type Collection, type CollectionId, generateCollectionId } from './collections'
|
export { type Collection, type CollectionId, generateCollectionId } from './collections'
|
||||||
|
export type {
|
||||||
|
MaterialMapProperties,
|
||||||
|
MaterialMaps,
|
||||||
|
MaterialPresetPayload,
|
||||||
|
MaterialTarget as MaterialTargetValue,
|
||||||
|
TextureWrapMode as TextureWrapModeValue,
|
||||||
|
} from './material'
|
||||||
// Material
|
// Material
|
||||||
export {
|
export {
|
||||||
DEFAULT_MATERIALS,
|
DEFAULT_MATERIALS,
|
||||||
@@ -14,15 +21,8 @@ export {
|
|||||||
MaterialProperties,
|
MaterialProperties,
|
||||||
MaterialSchema,
|
MaterialSchema,
|
||||||
MaterialTarget,
|
MaterialTarget,
|
||||||
TextureWrapMode,
|
|
||||||
resolveMaterial,
|
resolveMaterial,
|
||||||
} from './material'
|
TextureWrapMode,
|
||||||
export type {
|
|
||||||
MaterialMapProperties,
|
|
||||||
MaterialMaps,
|
|
||||||
MaterialPresetPayload,
|
|
||||||
MaterialTarget as MaterialTargetValue,
|
|
||||||
TextureWrapMode as TextureWrapModeValue,
|
|
||||||
} from './material'
|
} from './material'
|
||||||
export { BuildingNode } from './nodes/building'
|
export { BuildingNode } from './nodes/building'
|
||||||
export { CeilingNode } from './nodes/ceiling'
|
export { CeilingNode } from './nodes/ceiling'
|
||||||
@@ -58,7 +58,12 @@ export {
|
|||||||
} from './nodes/stair'
|
} from './nodes/stair'
|
||||||
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
||||||
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
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 { WindowNode } from './nodes/window'
|
||||||
export { ZoneNode } from './nodes/zone'
|
export { ZoneNode } from './nodes/zone'
|
||||||
export type { AnyNodeId, AnyNodeType } from './types'
|
export type { AnyNodeId, AnyNodeType } from './types'
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ export const WallNode = BaseNode.extend({
|
|||||||
children: z
|
children: z
|
||||||
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
|
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
|
||||||
.default([]),
|
.default([]),
|
||||||
|
// Legacy single-material wall finish. Read for backward compatibility only.
|
||||||
material: MaterialSchema.optional(),
|
material: MaterialSchema.optional(),
|
||||||
|
// Legacy single-material wall finish preset. Read for backward compatibility only.
|
||||||
materialPreset: z.string().optional(),
|
materialPreset: z.string().optional(),
|
||||||
|
interiorMaterial: MaterialSchema.optional(),
|
||||||
|
interiorMaterialPreset: z.string().optional(),
|
||||||
|
exteriorMaterial: MaterialSchema.optional(),
|
||||||
|
exteriorMaterialPreset: z.string().optional(),
|
||||||
thickness: z.number().optional(),
|
thickness: z.number().optional(),
|
||||||
height: z.number().optional(),
|
height: z.number().optional(),
|
||||||
curveOffset: 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 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 { CollectionId } from '../../schema/collections'
|
||||||
import type { SceneState } from '../use-scene'
|
import type { SceneState } from '../use-scene'
|
||||||
|
|
||||||
@@ -17,11 +23,7 @@ type WallMergePlan = {
|
|||||||
let pendingRafId: number | null = null
|
let pendingRafId: number | null = null
|
||||||
let pendingUpdates: Set<AnyNodeId> = new Set()
|
let pendingUpdates: Set<AnyNodeId> = new Set()
|
||||||
|
|
||||||
function pointsEqual(
|
function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) {
|
||||||
a: [number, number],
|
|
||||||
b: [number, number],
|
|
||||||
tolerance = 1e-6,
|
|
||||||
) {
|
|
||||||
const dx = a[0] - b[0]
|
const dx = a[0] - b[0]
|
||||||
const dz = a[1] - b[1]
|
const dz = a[1] - b[1]
|
||||||
return dx * dx + dz * dz <= tolerance * tolerance
|
return dx * dx + dz * dz <= tolerance * tolerance
|
||||||
@@ -40,32 +42,30 @@ function getWallEndpointAtPoint(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWallFreeEndpoint(
|
function getWallFreeEndpoint(wall: Pick<WallNode, 'start' | 'end'>, sharedPoint: [number, number]) {
|
||||||
wall: Pick<WallNode, 'start' | 'end'>,
|
|
||||||
sharedPoint: [number, number],
|
|
||||||
) {
|
|
||||||
return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start
|
return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start
|
||||||
}
|
}
|
||||||
|
|
||||||
function areWallStylesCompatible(a: WallNode, b: WallNode) {
|
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 (
|
return (
|
||||||
(a.parentId ?? null) === (b.parentId ?? null) &&
|
(a.parentId ?? null) === (b.parentId ?? null) &&
|
||||||
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
|
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.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
|
||||||
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
|
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
|
||||||
a.materialPreset === b.materialPreset &&
|
aInterior === bInterior &&
|
||||||
JSON.stringify(a.material ?? null) === JSON.stringify(b.material ?? null) &&
|
aExterior === bExterior &&
|
||||||
a.frontSide === b.frontSide &&
|
a.frontSide === b.frontSide &&
|
||||||
a.backSide === b.backSide &&
|
a.backSide === b.backSide &&
|
||||||
a.visible === b.visible
|
a.visible === b.visible
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function areWallsCollinearAcrossPoint(
|
function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) {
|
||||||
a: WallNode,
|
|
||||||
b: WallNode,
|
|
||||||
sharedPoint: [number, number],
|
|
||||||
) {
|
|
||||||
const freeA = getWallFreeEndpoint(a, sharedPoint)
|
const freeA = getWallFreeEndpoint(a, sharedPoint)
|
||||||
const freeB = getWallFreeEndpoint(b, sharedPoint)
|
const freeB = getWallFreeEndpoint(b, sharedPoint)
|
||||||
const ax = freeA[0] - sharedPoint[0]
|
const ax = freeA[0] - sharedPoint[0]
|
||||||
@@ -111,7 +111,10 @@ function buildMergedWallAttachmentUpdates(
|
|||||||
mergedEnd: [number, number],
|
mergedEnd: [number, number],
|
||||||
nodes: Record<AnyNodeId, AnyNode>,
|
nodes: Record<AnyNodeId, AnyNode>,
|
||||||
): WallAttachmentUpdate[] {
|
): 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 tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength
|
||||||
const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength
|
const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength
|
||||||
const updates: WallAttachmentUpdate[] = []
|
const updates: WallAttachmentUpdate[] = []
|
||||||
@@ -126,11 +129,16 @@ function buildMergedWallAttachmentUpdates(
|
|||||||
const sourceWall = child.parentId === secondary.id ? secondary : primary
|
const sourceWall = child.parentId === secondary.id ? secondary : primary
|
||||||
const sourceLength = Math.max(wallLength(sourceWall), 1e-6)
|
const sourceLength = Math.max(wallLength(sourceWall), 1e-6)
|
||||||
const localX = typeof child.position[0] === 'number' ? child.position[0] : 0
|
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 worldX =
|
||||||
const worldZ = sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength
|
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(
|
const nextLocalX = Math.max(
|
||||||
0,
|
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({
|
updates.push({
|
||||||
|
|||||||
@@ -100,6 +100,49 @@ function normalizeStairSegmentNode(node: Record<string, unknown>) {
|
|||||||
return parsed.success ? parsed.data : null
|
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> {
|
function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||||
const patchedNodes = { ...nodes }
|
const patchedNodes = { ...nodes }
|
||||||
for (const [id, node] of Object.entries(patchedNodes)) {
|
for (const [id, node] of Object.entries(patchedNodes)) {
|
||||||
@@ -153,6 +196,10 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
patchedNodes[id] = normalized
|
patchedNodes[id] = normalized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === 'wall') {
|
||||||
|
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return patchedNodes as Record<string, AnyNode>
|
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 { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||||
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
|
||||||
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
|
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
|
||||||
|
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
||||||
import {
|
import {
|
||||||
calculateLevelMiters,
|
calculateLevelMiters,
|
||||||
getAdjacentWallIds,
|
getAdjacentWallIds,
|
||||||
getWallMiterBoundaryPoints,
|
getWallMiterBoundaryPoints,
|
||||||
type Point2D,
|
type Point2D,
|
||||||
type WallMiterData,
|
|
||||||
pointToKey,
|
pointToKey,
|
||||||
|
type WallMiterData,
|
||||||
} from './wall-mitering'
|
} from './wall-mitering'
|
||||||
|
|
||||||
// Reusable CSG evaluator for better performance
|
// Reusable CSG evaluator for better performance
|
||||||
const csgEvaluator = new Evaluator()
|
const csgEvaluator = new Evaluator()
|
||||||
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
|
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) {
|
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||||
const uv = geometry.getAttribute('uv')
|
const uv = geometry.getAttribute('uv')
|
||||||
@@ -78,6 +88,207 @@ function insetCurvedWallBoundaryPointsFor3D(
|
|||||||
return next
|
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
|
// WALL SYSTEM
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -252,6 +463,7 @@ export function generateExtrudedWall(
|
|||||||
|
|
||||||
// Convert polygon to local coordinates
|
// Convert polygon to local coordinates
|
||||||
const localPoints = polyPoints.map(worldToLocal)
|
const localPoints = polyPoints.map(worldToLocal)
|
||||||
|
const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData)
|
||||||
|
|
||||||
// Build THREE.js shape
|
// Build THREE.js shape
|
||||||
// Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
|
// 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)
|
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
||||||
geometry.rotateX(-Math.PI / 2)
|
geometry.rotateX(-Math.PI / 2)
|
||||||
geometry.computeVertexNormals()
|
geometry.computeVertexNormals()
|
||||||
|
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
|
||||||
ensureUv2Attribute(geometry)
|
ensureUv2Attribute(geometry)
|
||||||
|
|
||||||
// Apply CSG subtraction for cutouts (doors/windows)
|
// Apply CSG subtraction for cutouts (doors/windows)
|
||||||
@@ -307,6 +520,7 @@ export function generateExtrudedWall(
|
|||||||
|
|
||||||
const resultGeometry = resultBrush.geometry
|
const resultGeometry = resultBrush.geometry
|
||||||
resultGeometry.computeVertexNormals()
|
resultGeometry.computeVertexNormals()
|
||||||
|
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
|
||||||
ensureUv2Attribute(resultGeometry)
|
ensureUv2Attribute(resultGeometry)
|
||||||
|
|
||||||
return resultGeometry
|
return resultGeometry
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
|
type WallEvent,
|
||||||
|
type WallSurfaceSide,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
@@ -68,6 +70,32 @@ export const resolveBuildingId = (
|
|||||||
return null
|
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 = {
|
const HIGHLIGHT_PROFILES = {
|
||||||
delete: {
|
delete: {
|
||||||
color: new Color('#dc2626'),
|
color: new Color('#dc2626'),
|
||||||
@@ -439,6 +467,23 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
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
|
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clickHandledRef.current = false
|
clickHandledRef.current = false
|
||||||
@@ -471,6 +516,7 @@ export const SelectionManager = () => {
|
|||||||
const { phase, structureLayer } = useEditor.getState()
|
const { phase, structureLayer } = useEditor.getState()
|
||||||
const activeStrategy = SELECTION_STRATEGIES[phase]
|
const activeStrategy = SELECTION_STRATEGIES[phase]
|
||||||
if (activeStrategy) activeStrategy.handleDeselect()
|
if (activeStrategy) activeStrategy.handleDeselect()
|
||||||
|
useEditor.getState().setSelectedWallMaterialTarget(null)
|
||||||
|
|
||||||
// When deselecting from zone mode, return to structure select
|
// When deselecting from zone mode, return to structure select
|
||||||
if (phase === 'structure' && structureLayer === 'zones') {
|
if (phase === 'structure' && structureLayer === 'zones') {
|
||||||
@@ -704,6 +750,12 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SelectionStateSync = () => {
|
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(() => {
|
useEffect(() => {
|
||||||
return useScene.subscribe((state) => {
|
return useScene.subscribe((state) => {
|
||||||
const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type MaterialTarget,
|
type MaterialTarget,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
type MaterialPickerProps = {
|
type MaterialPickerProps = {
|
||||||
nodeType?: MaterialTarget
|
nodeType?: MaterialTarget
|
||||||
@@ -14,6 +14,7 @@ type MaterialPickerProps = {
|
|||||||
selectedMaterialPreset?: string
|
selectedMaterialPreset?: string
|
||||||
onChange?: (material: MaterialSchema) => void
|
onChange?: (material: MaterialSchema) => void
|
||||||
onSelectMaterialPreset?: (materialPreset: string) => void
|
onSelectMaterialPreset?: (materialPreset: string) => void
|
||||||
|
hideSideControl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaterialPicker({
|
export function MaterialPicker({
|
||||||
@@ -22,10 +23,15 @@ export function MaterialPicker({
|
|||||||
selectedMaterialPreset,
|
selectedMaterialPreset,
|
||||||
onChange,
|
onChange,
|
||||||
onSelectMaterialPreset,
|
onSelectMaterialPreset,
|
||||||
|
hideSideControl = false,
|
||||||
}: MaterialPickerProps) {
|
}: MaterialPickerProps) {
|
||||||
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
|
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
|
||||||
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
|
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowCustom(!!value?.properties && !selectedMaterialPreset)
|
||||||
|
}, [selectedMaterialPreset, value?.properties])
|
||||||
|
|
||||||
const currentProps = value?.properties || {
|
const currentProps = value?.properties || {
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
roughness: 0.5,
|
roughness: 0.5,
|
||||||
@@ -193,6 +199,7 @@ export function MaterialPicker({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!hideSideControl && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="w-16 text-gray-500 text-xs">Side</label>
|
<label className="w-16 text-gray-500 text-xs">Side</label>
|
||||||
<select
|
<select
|
||||||
@@ -207,6 +214,7 @@ export function MaterialPicker({
|
|||||||
<option value="double">Double</option>
|
<option value="double">Double</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,17 +3,20 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
getEffectiveWallSurfaceMaterial,
|
||||||
getClampedWallCurveOffset,
|
getClampedWallCurveOffset,
|
||||||
getMaxWallCurveOffset,
|
getMaxWallCurveOffset,
|
||||||
getWallCurveLength,
|
getWallCurveLength,
|
||||||
|
getWallSurfaceMaterialSignature,
|
||||||
normalizeWallCurveOffset,
|
normalizeWallCurveOffset,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
useScene,
|
useScene,
|
||||||
|
type WallSurfaceSide,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Move, Spline } from 'lucide-react'
|
import { Move, Spline } from 'lucide-react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
@@ -22,12 +25,39 @@ import { PanelSection } from '../controls/panel-section'
|
|||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
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() {
|
export function WallPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||||
|
const selectedWallMaterialTarget = useEditor((s) => s.selectedWallMaterialTarget)
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||||
@@ -58,6 +88,33 @@ export function WallPanel() {
|
|||||||
[selectedId, updateNode],
|
[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(
|
const handleUpdateLength = useCallback(
|
||||||
(newLength: number) => {
|
(newLength: number) => {
|
||||||
if (!node || newLength <= 0) return
|
if (!node || newLength <= 0) return
|
||||||
@@ -83,16 +140,18 @@ export function WallPanel() {
|
|||||||
|
|
||||||
const handleMaterialPresetChange = useCallback(
|
const handleMaterialPresetChange = useCallback(
|
||||||
(materialPreset: string) => {
|
(materialPreset: string) => {
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
if (!node || !materialTargetSide) return
|
||||||
|
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
|
||||||
},
|
},
|
||||||
[handleUpdate],
|
[handleUpdate, materialTargetSide, node],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCustomMaterialChange = useCallback(
|
const handleCustomMaterialChange = useCallback(
|
||||||
(material: MaterialSchema) => {
|
(material: MaterialSchema) => {
|
||||||
handleUpdate({ material, materialPreset: undefined })
|
if (!node || !materialTargetSide) return
|
||||||
|
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
|
||||||
},
|
},
|
||||||
[handleUpdate],
|
[handleUpdate, materialTargetSide, node],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -177,15 +236,24 @@ export function WallPanel() {
|
|||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
<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
|
<MaterialPicker
|
||||||
|
hideSideControl
|
||||||
nodeType="wall"
|
nodeType="wall"
|
||||||
onChange={handleCustomMaterialChange}
|
onChange={handleCustomMaterialChange}
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||||
selectedMaterialPreset={node.materialPreset}
|
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||||
value={node.material}
|
value={materialPickerValue.material}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
{!hasWallChildrenBlockingCurve && (
|
{!hasWallChildrenBlockingCurve && (
|
||||||
@@ -196,6 +264,7 @@ export function WallPanel() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
type WallSurfaceSide,
|
||||||
type WindowNode,
|
type WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
@@ -80,6 +81,11 @@ export type MovingWallEndpoint = {
|
|||||||
endpoint: 'start' | 'end'
|
endpoint: 'start' | 'end'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SelectedWallMaterialTarget = {
|
||||||
|
wallId: WallNode['id']
|
||||||
|
side: WallSurfaceSide
|
||||||
|
}
|
||||||
|
|
||||||
type EditorState = {
|
type EditorState = {
|
||||||
phase: Phase
|
phase: Phase
|
||||||
setPhase: (phase: Phase) => void
|
setPhase: (phase: Phase) => void
|
||||||
@@ -127,6 +133,8 @@ type EditorState = {
|
|||||||
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
||||||
curvingWall: WallNode | null
|
curvingWall: WallNode | null
|
||||||
setCurvingWall: (wall: WallNode | null) => void
|
setCurvingWall: (wall: WallNode | null) => void
|
||||||
|
selectedWallMaterialTarget: SelectedWallMaterialTarget | null
|
||||||
|
setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void
|
||||||
selectedReferenceId: string | null
|
selectedReferenceId: string | null
|
||||||
setSelectedReferenceId: (id: string | null) => void
|
setSelectedReferenceId: (id: string | null) => void
|
||||||
// Space detection for cutaway mode
|
// Space detection for cutaway mode
|
||||||
@@ -502,6 +510,8 @@ const useEditor = create<EditorState>()(
|
|||||||
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
||||||
curvingWall: null,
|
curvingWall: null,
|
||||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||||
|
selectedWallMaterialTarget: null,
|
||||||
|
setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }),
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
spaces: {},
|
spaces: {},
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
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 type { Mesh } from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import {
|
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
|
||||||
createMaterial,
|
|
||||||
createMaterialFromPresetRef,
|
|
||||||
DEFAULT_WALL_MATERIAL,
|
|
||||||
} from '../../../lib/materials'
|
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
export const WallRenderer = ({ node }: { node: WallNode }) => {
|
export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||||
@@ -19,20 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'wall')
|
const handlers = useNodeEvents(node, 'wall')
|
||||||
|
const material = getVisibleWallMaterials(node)
|
||||||
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,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import type { ThreeEvent } from '@react-three/fiber'
|
import type { ThreeEvent } from '@react-three/fiber'
|
||||||
|
import type { BufferGeometry, Mesh } from 'three'
|
||||||
import useViewer from '../store/use-viewer'
|
import useViewer from '../store/use-viewer'
|
||||||
|
|
||||||
type NodeConfig = {
|
type NodeConfig = {
|
||||||
@@ -55,6 +56,23 @@ type NodeConfig = {
|
|||||||
|
|
||||||
type NodeType = keyof 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) {
|
export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], type: T) {
|
||||||
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
|
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
|
||||||
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`
|
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],
|
position: [e.point.x, e.point.y, e.point.z],
|
||||||
localPosition: [localPoint.x, localPoint.y, localPoint.z],
|
localPosition: [localPoint.x, localPoint.y, localPoint.z],
|
||||||
normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
|
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(),
|
stopPropagation: () => e.stopPropagation(),
|
||||||
nativeEvent: e,
|
nativeEvent: e,
|
||||||
} as NodeConfig[T]['event']
|
} as NodeConfig[T]['event']
|
||||||
|
|||||||
@@ -1,210 +1,14 @@
|
|||||||
import {
|
import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||||
type AnyNodeId,
|
|
||||||
baseMaterial,
|
|
||||||
emitter,
|
|
||||||
getMaterialPresetByRef,
|
|
||||||
sceneRegistry,
|
|
||||||
useScene,
|
|
||||||
type WallNode,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import type { Material } from 'three'
|
import type { Material } from 'three'
|
||||||
import { Color } from 'three'
|
import { type Mesh, Vector3 } from 'three/webgpu'
|
||||||
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
|
||||||
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
|
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
|
import { getMaterialsForWall } from './wall-materials'
|
||||||
|
|
||||||
const tmpVec = new Vector3()
|
const tmpVec = new Vector3()
|
||||||
const u = new Vector3()
|
const u = new Vector3()
|
||||||
const v = 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(
|
function getWallHideState(
|
||||||
wallNode: WallNode,
|
wallNode: WallNode,
|
||||||
@@ -301,7 +105,7 @@ export const WallCutout = () => {
|
|||||||
? materials.deleteVisible
|
? materials.deleteVisible
|
||||||
: isSelectionHighlighted
|
: isSelectionHighlighted
|
||||||
? materials.highlightedVisible
|
? materials.highlightedVisible
|
||||||
: getVisibleWallMaterial(wallNode)
|
: materials.visible
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
lastWallMode.current = wallMode
|
lastWallMode.current = wallMode
|
||||||
@@ -311,7 +115,7 @@ export const WallCutout = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const snapshot = new Map<Mesh, Material>()
|
const snapshot = new Map<Mesh, Material | Material[]>()
|
||||||
|
|
||||||
const restoreForCapture = () => {
|
const restoreForCapture = () => {
|
||||||
sceneRegistry.byType.wall.forEach((wallId) => {
|
sceneRegistry.byType.wall.forEach((wallId) => {
|
||||||
@@ -320,10 +124,10 @@ export const WallCutout = () => {
|
|||||||
const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined
|
const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined
|
||||||
if (!wallNode || wallNode.type !== 'wall') return
|
if (!wallNode || wallNode.type !== 'wall') return
|
||||||
const mats = getMaterialsForWall(wallNode)
|
const mats = getMaterialsForWall(wallNode)
|
||||||
const current = wallMesh.material as Material
|
const current = wallMesh.material as Material | Material[]
|
||||||
snapshot.set(wallMesh, current)
|
snapshot.set(wallMesh, current)
|
||||||
if (current === mats.highlightedVisible || current === mats.deleteVisible) {
|
if (current === mats.highlightedVisible || current === mats.deleteVisible) {
|
||||||
wallMesh.material = getVisibleWallMaterial(wallNode)
|
wallMesh.material = mats.visible
|
||||||
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
|
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
|
||||||
wallMesh.material = mats.invisible
|
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