Merge pull request #266 from sudhir9297/feat/nodes-material-system

feat: nodes material system and bug fix
This commit is contained in:
Wassim SAMAD
2026-04-20 15:10:28 -04:00
committed by GitHub
33 changed files with 1991 additions and 444 deletions
+3
View File
@@ -1,4 +1,5 @@
import type { ThreeEvent } from '@react-three/fiber'
import type { Object3D } from 'three'
import mitt from 'mitt'
import type {
BuildingNode,
@@ -38,6 +39,8 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
position: [number, number, number]
localPosition: [number, number, number]
normal?: [number, number, number]
faceIndex?: number
object: Object3D
stopPropagation: () => void
nativeEvent: ThreeEvent<PointerEvent>
}
+3 -3
View File
@@ -51,7 +51,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
id: 'wall-wood1',
label: 'Wood',
description: 'Warm wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
preset: {
maps: {
@@ -86,7 +86,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
id: 'wall-wood2',
label: 'Wood',
description: 'Textured wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
preset: {
maps: {
@@ -122,7 +122,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
id: 'wall-wood3',
label: 'Wood',
description: 'Knotted timber finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
preset: {
maps: {
+18 -10
View File
@@ -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'
@@ -43,22 +43,30 @@ export type {
} from './nodes/item'
export { getScaledDimensions, ItemNode } from './nodes/item'
export { LevelNode } from './nodes/level'
export { RoofNode } from './nodes/roof'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan'
// Nodes
export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab'
export {
getEffectiveStairSurfaceMaterial,
StairNode,
StairRailingMode,
StairSlabOpeningMode,
StairTopLandingMode,
StairType,
} from './nodes/stair'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } 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'
+77 -2
View File
@@ -1,14 +1,26 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import { RoofSegmentNode } from './roof-segment'
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
export type RoofSurfaceMaterialSpec = {
material?: MaterialSchema
materialPreset?: string
}
export const RoofNode = BaseNode.extend({
id: objectId('roof'),
type: nodeType('roof'),
material: MaterialSchema.optional(),
material: MaterialSchemaSchema.optional(),
materialPreset: z.string().optional(),
topMaterial: MaterialSchemaSchema.optional(),
topMaterialPreset: z.string().optional(),
edgeMaterial: MaterialSchemaSchema.optional(),
edgeMaterialPreset: z.string().optional(),
wallMaterial: MaterialSchemaSchema.optional(),
wallMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
@@ -26,3 +38,66 @@ export const RoofNode = BaseNode.extend({
)
export type RoofNode = z.infer<typeof RoofNode>
function getLegacyRoofSurfaceMaterial(node: RoofNode): RoofSurfaceMaterialSpec {
return {
material: node.material,
materialPreset: node.materialPreset,
}
}
export function getEffectiveRoofSurfaceMaterial(
node: RoofNode,
role: RoofSurfaceMaterialRole,
): RoofSurfaceMaterialSpec {
if (role === 'top') {
if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') {
return {
material: node.topMaterial,
materialPreset: typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
}
}
}
if (role === 'edge') {
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
return {
material: node.edgeMaterial,
materialPreset:
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined,
}
}
}
if (role === 'wall') {
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
return {
material: node.wallMaterial,
materialPreset:
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined,
}
}
}
if (role === 'edge') {
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
return {
material: node.wallMaterial,
materialPreset:
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined,
}
}
}
if (role === 'wall') {
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
return {
material: node.edgeMaterial,
materialPreset:
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined,
}
}
}
return getLegacyRoofSurfaceMaterial(node)
}
+84 -2
View File
@@ -1,7 +1,7 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
@@ -13,12 +13,23 @@ export type StairRailingMode = z.infer<typeof StairRailingMode>
export type StairType = z.infer<typeof StairType>
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side'
export type StairSurfaceMaterialSpec = {
material?: MaterialSchema
materialPreset?: string
}
export const StairNode = BaseNode.extend({
id: objectId('stair'),
type: nodeType('stair'),
material: MaterialSchema.optional(),
material: MaterialSchemaSchema.optional(),
materialPreset: z.string().optional(),
railingMaterial: MaterialSchemaSchema.optional(),
railingMaterialPreset: z.string().optional(),
treadMaterial: MaterialSchemaSchema.optional(),
treadMaterialPreset: z.string().optional(),
sideMaterial: MaterialSchemaSchema.optional(),
sideMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
@@ -71,3 +82,74 @@ export const StairNode = BaseNode.extend({
)
export type StairNode = z.infer<typeof StairNode>
function getLegacyStairSurfaceMaterial(node: StairNode): StairSurfaceMaterialSpec {
return {
material: node.material,
materialPreset: node.materialPreset,
}
}
export function getEffectiveStairSurfaceMaterial(
node: StairNode,
role: StairSurfaceMaterialRole,
): StairSurfaceMaterialSpec {
if (role === 'railing') {
if (node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string') {
return {
material: node.railingMaterial,
materialPreset:
typeof node.railingMaterialPreset === 'string' ? node.railingMaterialPreset : undefined,
}
}
}
if (role === 'tread') {
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
return {
material: node.treadMaterial,
materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
}
}
if (role === 'side') {
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
return {
material: node.sideMaterial,
materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
}
}
const treadFallback = {
material: node.treadMaterial,
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
const sideFallback = {
material: node.sideMaterial,
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
if (role === 'tread' && (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)) {
return sideFallback
}
if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) {
return treadFallback
}
if (role === 'railing') {
if (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined) {
return treadFallback
}
if (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined) {
return sideFallback
}
}
return getLegacyStairSurfaceMaterial(node)
}
+65
View File
@@ -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,
})
}
+29 -21
View File
@@ -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({
+190 -1
View File
@@ -100,6 +100,187 @@ 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 migrateStairSurfaceMaterials(node: Record<string, any>) {
const hasRailing =
node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string'
const hasTread = node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string'
const hasSide = node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string'
const legacyFinish = {
material: node.material,
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
}
const resolveBodyFallback = () => {
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
return {
material: node.treadMaterial,
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
}
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
return {
material: node.sideMaterial,
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
}
return legacyFinish
}
if (!hasRailing && !hasTread && !hasSide) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
return {
...node,
railingMaterial: legacyFinish.material,
railingMaterialPreset: legacyFinish.materialPreset,
treadMaterial: legacyFinish.material,
treadMaterialPreset: legacyFinish.materialPreset,
sideMaterial: legacyFinish.material,
sideMaterialPreset: legacyFinish.materialPreset,
}
}
const next = { ...node }
if (!hasTread) {
const fallback =
node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string'
? {
material: node.sideMaterial,
materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
: resolveBodyFallback()
next.treadMaterial = fallback.material
next.treadMaterialPreset = fallback.materialPreset
}
if (!hasSide) {
const fallback =
node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string'
? {
material: node.treadMaterial,
materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
: resolveBodyFallback()
next.sideMaterial = fallback.material
next.sideMaterialPreset = fallback.materialPreset
}
if (!hasRailing) {
const fallback = resolveBodyFallback()
next.railingMaterial = fallback.material
next.railingMaterialPreset = fallback.materialPreset
}
return next
}
function migrateRoofSurfaceMaterials(node: Record<string, any>) {
const hasTop = node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string'
const hasEdge = node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string'
const hasWall = node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string'
const legacyFinish = {
material: node.material,
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
}
if (!hasTop && !hasEdge && !hasWall) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
return {
...node,
topMaterial: legacyFinish.material,
topMaterialPreset: legacyFinish.materialPreset,
edgeMaterial: legacyFinish.material,
edgeMaterialPreset: legacyFinish.materialPreset,
wallMaterial: legacyFinish.material,
wallMaterialPreset: legacyFinish.materialPreset,
}
}
const next = { ...node }
if (!hasTop) {
next.topMaterial = legacyFinish.material
next.topMaterialPreset = legacyFinish.materialPreset
}
if (!hasEdge) {
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
next.edgeMaterial = node.wallMaterial
next.edgeMaterialPreset =
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined
} else {
next.edgeMaterial = legacyFinish.material
next.edgeMaterialPreset = legacyFinish.materialPreset
}
}
if (!hasWall) {
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
next.wallMaterial = node.edgeMaterial
next.wallMaterialPreset =
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined
} else {
next.wallMaterial = legacyFinish.material
next.wallMaterialPreset = legacyFinish.materialPreset
}
}
return next
}
function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
const patchedNodes = { ...nodes }
for (const [id, node] of Object.entries(patchedNodes)) {
@@ -141,7 +322,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
}
if (node.type === 'stair') {
const normalized = normalizeStairNode(node)
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) {
patchedNodes[id] = normalized
}
@@ -153,6 +334,14 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
patchedNodes[id] = normalized
}
}
if (node.type === 'wall') {
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
}
if (node.type === 'roof') {
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
}
}
return patchedNodes as Record<string, AnyNode>
}
@@ -20,26 +20,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index)
const py = position.getY(index)
const pz = position.getZ(index)
minX = Math.min(minX, px)
minY = Math.min(minY, py)
minZ = Math.min(minZ, pz)
maxX = Math.max(maxX, px)
maxY = Math.max(maxY, py)
maxZ = Math.max(maxZ, pz)
minX = Math.min(minX, position.getX(index))
minY = Math.min(minY, position.getY(index))
minZ = Math.min(minZ, position.getZ(index))
}
const width = Math.max(maxX - minX, 0.001)
const height = Math.max(maxY - minY, 0.001)
const depth = Math.max(maxZ - minZ, 0.001)
for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index)
const py = position.getY(index)
@@ -52,14 +39,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
let v = 0
if (ny >= nx && ny >= nz) {
u = (px - minX) / width
v = (pz - minZ) / depth
u = px - minX
v = pz - minZ
} else if (nx >= nz) {
u = (pz - minZ) / depth
v = (py - minY) / height
u = pz - minZ
v = py - minY
} else {
u = (px - minX) / width
v = (py - minY) / height
u = px - minX
v = py - minY
}
uvs[index * 2] = u
@@ -157,13 +144,17 @@ function generateFenceGeometry(fence: FenceNode) {
const geometries = parts.map((part) => {
const geometry = new THREE.BoxGeometry(1, 1, 1)
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
applyFenceUVs(geometry)
geometry.translate(part.position[0], part.position[1], part.position[2])
return geometry
})
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose())
applyFenceUVs(merged)
const mergedUv = merged.getAttribute('uv')
if (mergedUv) {
merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2))
}
merged.computeVertexNormals()
return merged
}
+38 -1
View File
@@ -11,7 +11,7 @@ import useScene from '../../store/use-scene'
const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
csgEvaluator.attributes = ['position', 'normal']
csgEvaluator.attributes = ['position', 'normal', 'uv']
function prepareBrushForCSG(brush: Brush) {
brush.geometry.computeBoundsTree = computeBoundsTree
@@ -25,6 +25,7 @@ const _position = new THREE.Vector3()
const _quaternion = new THREE.Quaternion()
const _scale = new THREE.Vector3(1, 1, 1)
const _yAxis = new THREE.Vector3(0, 1, 0)
const _uvFaceNormal = new THREE.Vector3()
// Pending merged-roof updates carried across frames (for throttling)
const pendingRoofUpdates = new Set<AnyNodeId>()
@@ -251,6 +252,7 @@ function updateMergedRoofGeometry(
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
}
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals()
mergedMesh.geometry.dispose()
mergedMesh.geometry = resultGeo
@@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
wallBrush.geometry.dispose()
innerBrush.geometry.dispose()
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals()
return resultGeo
}
@@ -936,6 +939,7 @@ function createGeometryFromFaces(
): THREE.BufferGeometry {
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const indices: number[] = []
const groups: { start: number; count: number; materialIndex: number }[] = []
let vertexCount = 0
@@ -974,6 +978,10 @@ function createGeometryFromFaces(
normals.push(normal.x, normal.y, normal.z)
normals.push(normal.x, normal.y, normal.z)
pushRoofUv(uvs, p0, normal)
pushRoofUv(uvs, fi, normal)
pushRoofUv(uvs, fi1, normal)
indices.push(vertexCount, vertexCount + 1, vertexCount + 2)
faceVertexCount += 3
@@ -990,6 +998,7 @@ function createGeometryFromFaces(
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setIndex(indices)
for (const g of groups) {
@@ -999,6 +1008,34 @@ function createGeometryFromFaces(
// Merge identical vertices to optimize geometry for CSG and create clean topology
const mergedGeo = mergeVertices(geometry, 1e-4)
geometry.dispose()
ensureUv2Attribute(mergedGeo)
return mergedGeo
}
function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) {
_uvFaceNormal.copy(normal).normalize()
const absX = Math.abs(_uvFaceNormal.x)
const absY = Math.abs(_uvFaceNormal.y)
const absZ = Math.abs(_uvFaceNormal.z)
if (absY >= absX && absY >= absZ) {
uvs.push(point.x, point.z)
return
}
if (absX >= absZ) {
uvs.push(point.z, point.y)
return
}
uvs.push(point.x, point.y)
}
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
@@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync'
const pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2
const MAX_SEGMENTS_PER_FRAME = 4
const STAIR_TREAD_MATERIAL_INDEX = 0
const STAIR_SIDE_MATERIAL_INDEX = 1
const _uvPosition = new THREE.Vector3()
const _uvNormal = new THREE.Vector3()
// ============================================================================
// STAIR SYSTEM
@@ -198,7 +202,7 @@ function generateStairSegmentGeometry(
shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, {
const extrudedGeometry = new THREE.ExtrudeGeometry(shape, {
steps: 1,
depth: width,
bevelEnabled: false,
@@ -209,7 +213,16 @@ function generateStairSegmentGeometry(
const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(width / 2, 0, 0)
geometry.applyMatrix4(matrix)
extrudedGeometry.applyMatrix4(matrix)
extrudedGeometry.computeVertexNormals()
const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry
if (geometry !== extrudedGeometry) {
extrudedGeometry.dispose()
}
applyStairSegmentUvs(geometry)
ensureUv2Attribute(geometry)
return geometry
}
@@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
const absoluteHeight = computeAbsoluteHeight(node)
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
applyStraightStairMaterialGroups(newGeometry)
mesh.geometry.dispose()
mesh.geometry = newGeometry
@@ -363,6 +377,7 @@ function updateMergedStairGeometry(
}
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
applyStraightStairMaterialGroups(merged)
replaceMeshGeometry(mergedMesh, merged)
// Dispose individual geometries
@@ -371,6 +386,108 @@ function updateMergedStairGeometry(
}
}
function applyStraightStairMaterialGroups(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
if (!position || position.count < 3) {
geometry.clearGroups()
return
}
const index = geometry.getIndex()
const triangleCount = index ? index.count / 3 : position.count / 3
if (!Number.isFinite(triangleCount) || triangleCount <= 0) {
geometry.clearGroups()
return
}
const triangleMaterials: number[] = new Array(triangleCount)
const v0 = new THREE.Vector3()
const v1 = new THREE.Vector3()
const v2 = new THREE.Vector3()
const edge1 = new THREE.Vector3()
const edge2 = new THREE.Vector3()
const normal = new THREE.Vector3()
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex++) {
const vertexOffset = triangleIndex * 3
const a = index ? index.getX(vertexOffset) : vertexOffset
const b = index ? index.getX(vertexOffset + 1) : vertexOffset + 1
const c = index ? index.getX(vertexOffset + 2) : vertexOffset + 2
v0.fromBufferAttribute(position, a)
v1.fromBufferAttribute(position, b)
v2.fromBufferAttribute(position, c)
edge1.subVectors(v1, v0)
edge2.subVectors(v2, v0)
normal.crossVectors(edge1, edge2)
triangleMaterials[triangleIndex] =
normal.lengthSq() > 0 && normal.normalize().y > 0.75
? STAIR_TREAD_MATERIAL_INDEX
: STAIR_SIDE_MATERIAL_INDEX
}
geometry.clearGroups()
let currentMaterial = triangleMaterials[0]
let groupStart = 0
for (let triangleIndex = 1; triangleIndex < triangleMaterials.length; triangleIndex++) {
const materialIndex = triangleMaterials[triangleIndex]
if (materialIndex === currentMaterial) continue
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
groupStart = triangleIndex
currentMaterial = materialIndex
}
geometry.addGroup(
groupStart * 3,
(triangleMaterials.length - groupStart) * 3,
currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX,
)
}
function applyStairSegmentUvs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
if (!position || !normal || position.count === 0) {
geometry.deleteAttribute('uv')
return
}
const uv: number[] = []
for (let index = 0; index < position.count; index++) {
_uvPosition.fromBufferAttribute(position, index)
_uvNormal.fromBufferAttribute(normal, index).normalize()
const absX = Math.abs(_uvNormal.x)
const absY = Math.abs(_uvNormal.y)
const absZ = Math.abs(_uvNormal.z)
if (absY >= absX && absY >= absZ) {
uv.push(_uvPosition.x, _uvPosition.z)
} else if (absX >= absZ) {
uv.push(_uvPosition.z, _uvPosition.y)
} else {
uv.push(_uvPosition.x, _uvPosition.y)
}
}
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))
}
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
// ============================================================================
// SEGMENT CHAINING
// ============================================================================
@@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
function createEmptyGeometry(): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX)
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
return geometry
}
+216 -2
View File
@@ -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
@@ -5,16 +5,24 @@ import {
emitter,
type ItemNode,
type NodeEvent,
type RoofEvent,
type RoofSegmentEvent,
resolveLevelId,
sceneRegistry,
type StairEvent,
type StairNode,
type StairSurfaceMaterialRole,
type StairSegmentEvent,
useScene,
type WallEvent,
type WallSurfaceSide,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react'
import { Color, type Material, type Mesh, type Object3D } from 'three'
import { Color, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
import useEditor, { type MaterialTargetRole, type Phase, type StructureLayer } from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -68,6 +76,123 @@ export const resolveBuildingId = (
return null
}
function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (materialIndex === 1) return 'interior'
if (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'
}
function resolveStairMaterialTarget(
event: StairEvent | StairSegmentEvent,
): StairSurfaceMaterialRole | null {
const hitObjectName = event.nativeEvent.object?.name ?? ''
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (hitObjectName.startsWith('stair-railing')) {
return 'railing'
}
if (hitObjectName.startsWith('stair-side')) {
return 'side'
}
if (materialIndex === 0) {
return 'tread'
}
if (materialIndex === 1) {
return 'side'
}
const normalY = event.normal?.[1]
if (normalY !== undefined && normalY > 0.75) {
return 'tread'
}
if (normalY !== undefined && Math.abs(normalY) <= 0.75) {
return 'side'
}
return null
}
function resolveRoofMaterialTarget(
event: RoofEvent | RoofSegmentEvent,
): 'top' | 'edge' | 'wall' | null {
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (materialIndex === 3) return 'top'
if (materialIndex === 0) return 'edge'
if (materialIndex === 1 || materialIndex === 2) return 'wall'
const normalY = event.normal?.[1]
if (normalY !== undefined && normalY > 0.35) return 'top'
if (normalY !== undefined && Math.abs(normalY) <= 0.35) return 'edge'
if (normalY !== undefined && normalY < -0.35) return 'wall'
return null
}
function getEventObject(event: NodeEvent): Object3D {
const eventWithObject = event as NodeEvent & { object?: Object3D }
return eventWithObject.object ?? event.nativeEvent.object
}
function getIntersectionMaterialIndex(
object: Object3D,
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
}
function setSelectedMaterialTargetForNode(
node: AnyNode,
role: MaterialTargetRole | null,
) {
if (!role) {
const currentTarget = useEditor.getState().selectedMaterialTarget
if (currentTarget?.nodeId !== node.id) {
useEditor.getState().setSelectedMaterialTarget(null)
}
return
}
useEditor.getState().setSelectedMaterialTarget({
nodeId: node.id as AnyNodeId,
role,
})
}
const HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
@@ -439,6 +564,42 @@ export const SelectionManager = () => {
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
let nextMaterialTargetHandled = false
if (node.type === 'wall' && nodeToSelect.type === 'wall') {
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveWallMaterialTarget(event as WallEvent),
)
nextMaterialTargetHandled = true
}
if (
(node.type === 'stair' || node.type === 'stair-segment') &&
nodeToSelect.type === 'stair'
) {
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent),
)
nextMaterialTargetHandled = true
}
if (
(node.type === 'roof' || node.type === 'roof-segment') &&
nodeToSelect.type === 'roof'
) {
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent),
)
nextMaterialTargetHandled = true
}
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
useEditor.getState().setSelectedMaterialTarget(null)
}
// Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => {
clickHandledRef.current = false
@@ -471,6 +632,7 @@ export const SelectionManager = () => {
const { phase, structureLayer } = useEditor.getState()
const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedMaterialTarget(null)
// When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') {
@@ -704,6 +866,12 @@ export const SelectionManager = () => {
}
const SelectionStateSync = () => {
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
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 +900,28 @@ const SelectionStateSync = () => {
})
}, [])
useEffect(() => {
if (!selectedMaterialTarget) return
if (!singleSelectedId) {
setSelectedMaterialTarget(null)
return
}
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if (
!selectedNode ||
(selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof')
) {
setSelectedMaterialTarget(null)
return
}
if (selectedMaterialTarget.nodeId !== selectedNode.id) {
setSelectedMaterialTarget(null)
}
}, [selectedMaterialTarget, setSelectedMaterialTarget, singleSelectedId])
return null
}
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
* Imperatively toggles the Three.js visibility of roof objects based on the
* editor selection — without causing React re-renders in RoofRenderer.
*
* When a roof (or one of its segments) is selected:
* When a roof-segment is selected:
* - merged-roof mesh is hidden
* - segments-wrapper group is shown (individual segments visible for editing)
* - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -22,14 +22,14 @@ export const RoofEditSystem = () => {
useEffect(() => {
const nodes = useScene.getState().nodes
// Collect which roof nodes should be in "edit mode"
// Collect which roof nodes should be in "edit mode".
// Selecting the roof itself should keep the merged visual intact so
// material appearance does not jump between merged and per-segment meshes.
const activeRoofIds = new Set<string>()
for (const id of selectedIds) {
const node = nodes[id as AnyNodeId]
if (!node) continue
if (node.type === 'roof') {
activeRoofIds.add(id)
} else if (node.type === 'roof-segment' && node.parentId) {
if (node.type === 'roof-segment' && node.parentId) {
activeRoofIds.add(node.parentId)
}
}
@@ -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,8 @@ type MaterialPickerProps = {
selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
hideSideControl?: boolean
disabled?: boolean
}
export function MaterialPicker({
@@ -22,10 +24,16 @@ export function MaterialPicker({
selectedMaterialPreset,
onChange,
onSelectMaterialPreset,
hideSideControl = false,
disabled = 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,
@@ -38,11 +46,13 @@ export function MaterialPicker({
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handleCatalogSelect = (materialId: string) => {
if (disabled) return
setShowCustom(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
}
const handleCustomOpen = () => {
if (disabled) return
setShowCustom(true)
onChange?.({
preset: 'custom',
@@ -61,6 +71,7 @@ export function MaterialPicker({
prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps],
) => {
if (disabled) return
onChange?.({
preset: 'custom',
properties: {
@@ -71,7 +82,7 @@ export function MaterialPicker({
}
return (
<div className="space-y-3">
<div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && (
<div className="space-y-2">
{catalogItems.length > 0 ? (
@@ -193,20 +204,22 @@ export function MaterialPicker({
</span>
</div>
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Side</label>
<select
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) =>
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
}
value={currentProps.side}
>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="double">Double</option>
</select>
</div>
{!hideSideControl && (
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Side</label>
<select
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) =>
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
}
value={currentProps.side}
>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="double">Double</option>
</select>
</div>
)}
</div>
)}
</div>
@@ -21,6 +21,20 @@ function stepPrecision(s: number): number {
return Math.max(0, Math.ceil(-Math.log10(s)))
}
function getAdjustedStep(
baseStep: number,
modifiers: {
shiftKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
altKey?: boolean
},
): number {
if (modifiers.shiftKey) return baseStep * 10
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1
return baseStep
}
export function SliderControl({
label,
value,
@@ -58,16 +72,14 @@ export function SliderControl({
if (isEditing) return
e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1
let s = step
if (e.shiftKey) s = step * 10
else if (e.altKey) s = step * 0.1
const s = getAdjustedStep(step, e)
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
}
el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
}, [isEditing, step, clamp, onChange])
// Arrow key support while hovered
useEffect(() => {
@@ -78,9 +90,7 @@ export function SliderControl({
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
if (direction !== 0) {
e.preventDefault()
let s = step
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
const s = getAdjustedStep(step, e)
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
@@ -88,7 +98,7 @@ export function SliderControl({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
}, [isHovered, isEditing, step, clamp, onChange])
const handleLabelPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
@@ -107,16 +117,14 @@ export function SliderControl({
if (!dragRef.current) return
const { startX, startValue } = dragRef.current
const dx = e.clientX - startX
let s = step
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
const s = getAdjustedStep(step, e)
// 4 px per step at default sensitivity
const newValue = clamp(
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
)
onChange(newValue)
},
[step, precision, clamp, onChange],
[step, clamp, onChange],
)
const handleLabelPointerUp = useCallback(
@@ -163,12 +171,18 @@ export function SliderControl({
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
const adjustedStep = getAdjustedStep(step, e)
const newV = clamp(
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV)
setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
const adjustedStep = getAdjustedStep(step, e)
const newV = clamp(
Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV)
setInputValue(newV.toFixed(precision))
}
@@ -3,8 +3,10 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
type MaterialSchema,
type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
@@ -22,12 +24,39 @@ import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
function buildRoofSurfaceMaterialPatch(
node: RoofNode,
targetRole: RoofSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<RoofNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextTop =
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
const nextEdge =
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
const nextWall =
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
return {
topMaterial: nextTop.material,
topMaterialPreset: nextTop.materialPreset,
edgeMaterial: nextEdge.material,
edgeMaterialPreset: nextEdge.materialPreset,
wallMaterial: nextWall.material,
wallMaterialPreset: nextWall.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function RoofPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -50,18 +79,31 @@ export function RoofPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'top' ||
selectedMaterialTarget.role === 'edge' ||
selectedMaterialTarget.role === 'wall')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
if (!node || !materialTargetRole) return
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate],
[handleUpdate, materialTargetRole, node],
)
const handleMaterialPresetChange = useCallback(
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
if (!node || !materialTargetRole) return
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
},
[handleUpdate],
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
@@ -170,11 +212,13 @@ export function RoofPanel() {
</button>
))}
</div>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Segment"
onClick={handleAddSegment}
/>
<ActionGroup>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Segment"
onClick={handleAddSegment}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Position">
@@ -267,12 +311,19 @@ export function RoofPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
{!materialTargetRole ? (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the roof surface you want to edit. Materials apply to one target at a time.
</div>
) : null}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="roof"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper>
@@ -3,10 +3,12 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveStairSurfaceMaterial,
type LevelNode,
type MaterialSchema,
type StairNode,
type StairRailingMode,
type StairSurfaceMaterialRole,
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
@@ -31,6 +33,32 @@ import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
function buildStairSurfaceMaterialPatch(
node: StairNode,
targetRole: StairSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<StairNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextRailing =
targetRole === 'railing' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'railing')
const nextTread =
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
const nextSide =
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
return {
railingMaterial: nextRailing.material,
railingMaterialPreset: nextRailing.materialPreset,
treadMaterial: nextTread.material,
treadMaterialPreset: nextTread.materialPreset,
sideMaterial: nextSide.material,
sideMaterialPreset: nextSide.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
{ label: 'None', value: 'none' },
{ label: 'Left', value: 'left' },
@@ -62,6 +90,7 @@ export function StairPanel() {
const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -92,18 +121,31 @@ export function StairPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'railing' ||
selectedMaterialTarget.role === 'tread' ||
selectedMaterialTarget.role === 'side')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
if (!node || !materialTargetRole) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate],
[handleUpdate, materialTargetRole, node],
)
const handleMaterialPresetChange = useCallback(
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
if (!node || !materialTargetRole) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
},
[handleUpdate],
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
@@ -569,12 +611,19 @@ export function StairPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
{!materialTargetRole ? (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the stair surface you want to edit. Materials apply to one target at a time.
</div>
) : null}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="stair"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper>
@@ -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 selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -58,6 +88,35 @@ 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 =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
? selectedMaterialTarget.role
: null
const materialPickerValue =
materialTargetSide === 'interior'
? effectiveInteriorMaterial
: materialTargetSide === 'exterior'
? effectiveExteriorMaterial
: surfaceMaterialsMatch
? effectiveInteriorMaterial
: {}
const handleUpdateLength = useCallback(
(newLength: number) => {
if (!node || newLength <= 0) return
@@ -83,16 +142,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(() => {
@@ -169,7 +230,7 @@ export function WallPanel() {
min={-Math.max(0.01, maxCurveOffset)}
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
precision={2}
step={0.01}
step={0.1}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
@@ -177,25 +238,34 @@ 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}
<MaterialPicker
disabled={!materialTargetSide}
hideSideControl
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
+16 -1
View File
@@ -1,21 +1,25 @@
'use client'
import type { AssetInput } from '@pascal-app/core'
import {
type AnyNodeId,
type AssetInput,
type BuildingNode,
type CeilingNode,
type DoorNode,
type FenceNode,
type ItemNode,
type LevelNode,
type RoofSurfaceMaterialRole,
type RoofNode,
type RoofSegmentNode,
type SlabNode,
type Space,
type StairSurfaceMaterialRole,
type StairNode,
type StairSegmentNode,
useScene,
type WallNode,
type WallSurfaceSide,
type WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
@@ -80,6 +84,13 @@ export type MovingWallEndpoint = {
endpoint: 'start' | 'end'
}
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
export type SelectedMaterialTarget = {
nodeId: AnyNodeId
role: MaterialTargetRole
}
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
@@ -127,6 +138,8 @@ type EditorState = {
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
@@ -502,6 +515,8 @@ const useEditor = create<EditorState>()(
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {},
@@ -1,9 +1,9 @@
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import type * as THREE from 'three'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
@@ -16,16 +16,22 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const debugColors = useViewer((s) => s.debugColors)
const parentNode =
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
geometry.addGroup(0, 0, 3)
return geometry
}, [])
const customMaterial = useMemo(() => {
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
const effectiveMaterial = node.material ?? parentNode?.material
if (node.material !== undefined || typeof node.materialPreset === 'string') {
return null
}
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return null
return createMaterial(mat)
return parentNode ? getRoofMaterialArray(parentNode) : null
}, [
node.materialPreset,
node.material,
@@ -37,21 +43,31 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
parentNode?.topMaterial,
parentNode?.topMaterialPreset,
parentNode?.edgeMaterial,
parentNode?.edgeMaterialPreset,
parentNode?.wallMaterial,
parentNode?.wallMaterialPreset,
])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return (
<mesh
geometry={placeholderGeometry}
material={material}
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
{/* RoofSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
/>
)
}
@@ -1,9 +1,9 @@
import { type RoofNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import type * as THREE from 'three'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { NodeRenderer } from '../node-renderer'
import { roofDebugMaterials, roofMaterials } from './roof-materials'
@@ -14,17 +14,41 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors)
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
geometry.addGroup(0, 0, 3)
return geometry
}, [])
const customMaterial = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return null
return createMaterial(mat)
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
const customMaterial = useMemo(
() => getRoofMaterialArray(node),
[
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.topMaterial,
node.topMaterialPreset,
node.edgeMaterial,
node.edgeMaterialPreset,
node.wallMaterial,
node.wallMaterialPreset,
],
)
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return (
<group
position={node.position}
@@ -33,9 +57,13 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
visible={node.visible}
{...handlers}
>
<mesh castShadow material={material} name="merged-roof" receiveShadow>
<boxGeometry args={[0, 0, 0]} />
</mesh>
<mesh
castShadow
geometry={placeholderGeometry}
material={material}
name="merged-roof"
receiveShadow
/>
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
@@ -55,7 +55,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
})
const next = nodeList
.filter((n): n is SlabNode => n.type === 'slab' && n.visible && n.polygon.length >= 3)
.filter(
(n): n is SlabNode =>
n.type === 'slab' &&
n.visible &&
n.polygon.length >= 3 &&
// Only recessed slabs should punch through the site ground.
// Positive slabs are real floor geometry and should not create a
// ghost footprint in the background ground fill.
(n.elevation ?? 0.05) < 0,
)
.filter((n) => {
if (!Number.isFinite(lowestLevelIndex)) return true
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
@@ -1,11 +1,11 @@
import { type SlabNode, useRegistry } from '@pascal-app/core'
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import {
applyMaterialPresetToMaterials,
createMaterial,
createMaterialFromPresetRef,
DEFAULT_SLAB_MATERIAL,
} from '../../../lib/materials'
@@ -17,9 +17,18 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const handlers = useNodeEvents(node, 'slab')
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL)
const slabMaterial = sourceMaterial.clone()
const preset = getMaterialPresetByRef(node.materialPreset)
const slabMaterial = preset
? new THREE.MeshStandardMaterial()
: node.material
? createMaterial(node.material).clone()
: DEFAULT_SLAB_MATERIAL.clone()
if (preset) {
// Apply the preset to the slab-owned material so async texture loads update
// the instance we actually render after refresh as well.
applyMaterialPresetToMaterials(slabMaterial, preset)
}
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
// pipeline variants that can fail when geometry is regenerated while a
@@ -1,8 +1,8 @@
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials'
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!)
@@ -19,14 +19,7 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
const material = useMemo(() => {
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
const effectiveMaterial = node.material ?? parentNode?.material
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
return getStraightStairSegmentBodyMaterials(node, parentNode)
}, [
node.materialPreset,
node.material,
@@ -38,19 +31,37 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
parentNode?.railingMaterialPreset,
parentNode?.railingMaterial,
parentNode?.sideMaterialPreset,
parentNode?.sideMaterial,
parentNode?.treadMaterialPreset,
parentNode?.treadMaterial,
])
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
return geometry
}, [])
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return (
<mesh
geometry={placeholderGeometry}
material={material}
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
{/* StairSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
/>
)
}
@@ -5,14 +5,15 @@ import {
useRegistry,
useScene,
} from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
getStairRailingMaterial,
getStairBodyMaterials,
type StairBodyMaterials,
} from '../../../systems/stair/stair-materials'
import { NodeRenderer } from '../node-renderer'
type SegmentTransform = {
@@ -71,6 +72,48 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
node.material?.texture,
])
const straightBodyMaterials = useMemo(
() => getStairBodyMaterials(node),
[
node.material,
node.materialPreset,
node.railingMaterial,
node.railingMaterialPreset,
node.sideMaterial,
node.sideMaterialPreset,
node.treadMaterial,
node.treadMaterialPreset,
],
)
const railingMaterial = useMemo(
() => getStairRailingMaterial(node),
[
node.material,
node.materialPreset,
node.railingMaterial,
node.railingMaterialPreset,
node.sideMaterial,
node.sideMaterialPreset,
node.treadMaterial,
node.treadMaterialPreset,
],
)
const straightPlaceholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
return geometry
}, [])
useEffect(() => {
return () => {
straightPlaceholderGeometry.dispose()
}
}, [straightPlaceholderGeometry])
return (
<group
position-x={node.position[0]}
@@ -81,12 +124,16 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
{...handlers}
>
{isSegmentBasedStair ? (
<mesh castShadow material={material} name="merged-stair" receiveShadow>
<boxGeometry args={[0, 0, 0]} />
</mesh>
<mesh
castShadow
geometry={straightPlaceholderGeometry}
material={straightBodyMaterials}
name="merged-stair"
receiveShadow
/>
) : null}
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null}
<StairRailings material={material} stair={node} />
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
<StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
@@ -170,6 +217,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY}
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
material={material}
name="stair-railing-baluster"
position={[point[0], point[1] + railHeight / 2, point[2]]}
receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]}
@@ -227,6 +275,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY}
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
material={material}
name="stair-railing-baluster"
position={[point[2], point[1] + railHeight / 2, point[0]]}
receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]}
@@ -333,6 +382,8 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
const RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
const STAIR_TREAD_MATERIAL_INDEX = 0
const STAIR_SIDE_MATERIAL_INDEX = 1
function RailSegment({
start,
@@ -367,6 +418,7 @@ function RailSegment({
castShadow
geometry={RAIL_GEOMETRY}
material={material}
name="stair-railing-rail"
position={[midpoint.x, midpoint.y, midpoint.z]}
quaternion={quaternion}
receiveShadow
@@ -375,7 +427,14 @@ function RailSegment({
)
}
function CurvedStairBody({ stair, material }: { stair: StairNode; material: THREE.Material }) {
function CurvedStairBody({
stair,
bodyMaterials,
}: {
stair: StairNode
bodyMaterials: StairBodyMaterials
}) {
const sideMaterial = bodyMaterials[1]
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
const stepHeight = totalRise / stepCount
@@ -411,7 +470,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<mesh
castShadow
receiveShadow
material={material}
material={sideMaterial}
name="stair-side"
position={[0, spiralColumnHeight / 2, 0]}
>
<cylinderGeometry
@@ -443,7 +503,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
{isSpiral && (stair.showStepSupports ?? true) ? (
<mesh
castShadow
material={material}
material={sideMaterial}
name="stair-side"
position={[
Math.cos(midAngle) *
(spiralColumnRadius +
@@ -470,7 +531,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh
endAngle={endAngle}
innerRadius={innerRadius}
material={material}
material={bodyMaterials}
outerRadius={outerRadius}
positionY={0}
startAngle={startAngle}
@@ -484,7 +545,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh
endAngle={sweepAngle / 2 + spiralLandingSweep}
innerRadius={innerRadius}
material={material}
material={bodyMaterials}
outerRadius={outerRadius}
positionY={spiralLastStepTop}
startAngle={sweepAngle / 2}
@@ -513,7 +574,7 @@ function CurvedStepMesh({
stepHeight: number
thickness: number
positionY: number
material: THREE.Material
material: THREE.Material | THREE.Material[]
}) {
const geometry = useMemo(
() =>
@@ -556,15 +617,39 @@ function buildCurvedStepGeometry(
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const triangleMaterialIndices: number[] = []
const pointOnArc = (radius: number, angle: number, y: number) =>
new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius)
const pushUv = (point: THREE.Vector3, normal: THREE.Vector3, materialIndex: number) => {
if (materialIndex === STAIR_TREAD_MATERIAL_INDEX) {
const angle = Math.atan2(point.z, point.x)
const arcOffset = (angle - startAngle) * Math.max((innerRadius + outerRadius) * 0.5, 0.01)
uvs.push(arcOffset, Math.sqrt(point.x * point.x + point.z * point.z) - innerRadius)
return
}
const absX = Math.abs(normal.x)
const absY = Math.abs(normal.y)
const absZ = Math.abs(normal.z)
if (absY >= absX && absY >= absZ) {
uvs.push(point.x, point.z)
} else if (absX >= absZ) {
uvs.push(point.z, point.y)
} else {
uvs.push(point.x, point.y)
}
}
const pushTriangle = (
a: THREE.Vector3,
b: THREE.Vector3,
c: THREE.Vector3,
normal: THREE.Vector3,
materialIndex: number,
) => {
const edgeAB = b.clone().sub(a)
const edgeAC = c.clone().sub(a)
@@ -573,7 +658,9 @@ function buildCurvedStepGeometry(
for (const point of ordered) {
positions.push(point.x, point.y, point.z)
normals.push(normal.x, normal.y, normal.z)
pushUv(point, normal, materialIndex)
}
triangleMaterialIndices.push(materialIndex)
}
const pushQuad = (
@@ -582,9 +669,10 @@ function buildCurvedStepGeometry(
c: THREE.Vector3,
d: THREE.Vector3,
normal: THREE.Vector3,
materialIndex: number,
) => {
pushTriangle(a, b, c, normal)
pushTriangle(a, c, d, normal)
pushTriangle(a, b, c, normal, materialIndex)
pushTriangle(a, c, d, normal, materialIndex)
}
const upNormal = new THREE.Vector3(0, 1, 0)
@@ -609,10 +697,38 @@ function buildCurvedStepGeometry(
const outerNormal = new THREE.Vector3(Math.cos(midAngle), 0, Math.sin(midAngle)).normalize()
const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize()
pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal)
pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal)
pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal)
pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal)
pushQuad(
innerStartTop,
outerStartTop,
outerEndTop,
innerEndTop,
upNormal,
STAIR_TREAD_MATERIAL_INDEX,
)
pushQuad(
innerStartBottom,
innerEndBottom,
outerEndBottom,
outerStartBottom,
downNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
innerStartBottom,
innerStartTop,
innerEndTop,
innerEndBottom,
innerNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
outerStartBottom,
outerEndBottom,
outerEndTop,
outerStartTop,
outerNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
}
const startInnerBottom = pointOnArc(innerRadius, startAngle, y0)
@@ -634,12 +750,49 @@ function buildCurvedStepGeometry(
sweepDirection * Math.cos(endAngle),
).normalize()
pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal)
pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal)
pushQuad(
startInnerBottom,
startOuterBottom,
startOuterTop,
startInnerTop,
startNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
endInnerBottom,
endInnerTop,
endOuterTop,
endOuterBottom,
endNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.clearGroups()
let currentMaterial = triangleMaterialIndices[0]
let groupStart = 0
for (let triangleIndex = 1; triangleIndex < triangleMaterialIndices.length; triangleIndex++) {
const materialIndex = triangleMaterialIndices[triangleIndex]
if (materialIndex === currentMaterial) continue
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
groupStart = triangleIndex
currentMaterial = materialIndex
}
if (triangleMaterialIndices.length > 0) {
geometry.addGroup(
groupStart * 3,
(triangleMaterialIndices.length - groupStart) * 3,
currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX,
)
}
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
geometry.computeVertexNormals()
return geometry
}
@@ -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}>
@@ -38,7 +38,15 @@ export const GroundOccluder = () => {
const polygons: [number, number][][] = []
Object.values(nodes).forEach((node) => {
if (!(node.type === 'slab' && node.visible && node.polygon.length >= 3)) {
if (
!(
node.type === 'slab' &&
node.visible &&
node.polygon.length >= 3 &&
// Only recessed slabs should punch through the ground plane.
(node.elevation ?? 0.05) < 0
)
) {
return
}
@@ -64,6 +64,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,
object: e.object,
stopPropagation: () => e.stopPropagation(),
nativeEvent: e,
} as NodeConfig[T]['event']
@@ -0,0 +1,46 @@
import {
getEffectiveRoofSurfaceMaterial,
type RoofNode,
type RoofSegmentNode,
} from '@pascal-app/core'
import * as THREE from 'three'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
function createResolvedMaterial(
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
materialPreset: string | undefined,
): THREE.Material | null {
if (materialPreset) {
return createMaterialFromPresetRef(materialPreset)
}
if (material) {
return createMaterial(material)
}
return null
}
export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
const wall = getEffectiveRoofSurfaceMaterial(node, 'wall')
const topMaterial = createResolvedMaterial(top.material, top.materialPreset)
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset)
if (!(topMaterial || edgeMaterial || wallMaterial)) {
return null
}
return [
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(),
]
}
@@ -0,0 +1,59 @@
import {
getEffectiveStairSurfaceMaterial,
type StairNode,
type StairSegmentNode,
} from '@pascal-app/core'
import type * as THREE from 'three'
import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../lib/materials'
export type StairBodyMaterials = [THREE.Material, THREE.Material]
function createResolvedMaterial(
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
materialPreset: string | undefined,
): THREE.Material {
if (materialPreset) {
return createMaterialFromPresetRef(materialPreset) ?? DEFAULT_STAIR_MATERIAL
}
if (material) {
return createMaterial(material)
}
return DEFAULT_STAIR_MATERIAL
}
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
return [
createResolvedMaterial(tread.material, tread.materialPreset),
createResolvedMaterial(side.material, side.materialPreset),
]
}
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
return createResolvedMaterial(railing.material, railing.materialPreset)
}
export function getStraightStairSegmentBodyMaterials(
segment: StairSegmentNode,
parentNode?: StairNode,
): StairBodyMaterials {
if (segment.material !== undefined || typeof segment.materialPreset === 'string') {
const override = createResolvedMaterial(segment.material, segment.materialPreset)
return [override, override]
}
if (parentNode) {
return getStairBodyMaterials(parentNode)
}
return [DEFAULT_STAIR_MATERIAL, DEFAULT_STAIR_MATERIAL]
}
@@ -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
}