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 { ThreeEvent } from '@react-three/fiber'
import type { Object3D } from 'three'
import mitt from 'mitt' import mitt from 'mitt'
import type { import type {
BuildingNode, BuildingNode,
@@ -38,6 +39,8 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
position: [number, number, number] position: [number, number, number]
localPosition: [number, number, number] localPosition: [number, number, number]
normal?: [number, number, number] normal?: [number, number, number]
faceIndex?: number
object: Object3D
stopPropagation: () => void stopPropagation: () => void
nativeEvent: ThreeEvent<PointerEvent> nativeEvent: ThreeEvent<PointerEvent>
} }
+3 -3
View File
@@ -51,7 +51,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
id: 'wall-wood1', id: 'wall-wood1',
label: 'Wood', label: 'Wood',
description: 'Warm wood finish', 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', previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -86,7 +86,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
id: 'wall-wood2', id: 'wall-wood2',
label: 'Wood', label: 'Wood',
description: 'Textured wood finish', 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', previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -122,7 +122,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
id: 'wall-wood3', id: 'wall-wood3',
label: 'Wood', label: 'Wood',
description: 'Knotted timber finish', 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', previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
preset: { preset: {
maps: { maps: {
+18 -10
View File
@@ -4,6 +4,13 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base'
export { CameraSchema } from './camera' export { CameraSchema } from './camera'
// Collections // Collections
export { type Collection, type CollectionId, generateCollectionId } from './collections' export { type Collection, type CollectionId, generateCollectionId } from './collections'
export type {
MaterialMapProperties,
MaterialMaps,
MaterialPresetPayload,
MaterialTarget as MaterialTargetValue,
TextureWrapMode as TextureWrapModeValue,
} from './material'
// Material // Material
export { export {
DEFAULT_MATERIALS, DEFAULT_MATERIALS,
@@ -14,15 +21,8 @@ export {
MaterialProperties, MaterialProperties,
MaterialSchema, MaterialSchema,
MaterialTarget, MaterialTarget,
TextureWrapMode,
resolveMaterial, resolveMaterial,
} from './material' TextureWrapMode,
export type {
MaterialMapProperties,
MaterialMaps,
MaterialPresetPayload,
MaterialTarget as MaterialTargetValue,
TextureWrapMode as TextureWrapModeValue,
} from './material' } from './material'
export { BuildingNode } from './nodes/building' export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling' export { CeilingNode } from './nodes/ceiling'
@@ -43,22 +43,30 @@ export type {
} from './nodes/item' } from './nodes/item'
export { getScaledDimensions, ItemNode } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item'
export { LevelNode } from './nodes/level' 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 { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan' export { ScanNode } from './nodes/scan'
// Nodes // Nodes
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' export { SlabNode } from './nodes/slab'
export { export {
getEffectiveStairSurfaceMaterial,
StairNode, StairNode,
StairRailingMode, StairRailingMode,
StairSlabOpeningMode, StairSlabOpeningMode,
StairTopLandingMode, StairTopLandingMode,
StairType, StairType,
} from './nodes/stair' } from './nodes/stair'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { WallNode } from './nodes/wall' export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
export {
getEffectiveWallSurfaceMaterial,
getWallSurfaceMaterialSignature,
WallNode,
} from './nodes/wall'
export { WindowNode } from './nodes/window' export { WindowNode } from './nodes/window'
export { ZoneNode } from './nodes/zone' export { ZoneNode } from './nodes/zone'
export type { AnyNodeId, AnyNodeType } from './types' export type { AnyNodeId, AnyNodeType } from './types'
+77 -2
View File
@@ -1,14 +1,26 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import { RoofSegmentNode } from './roof-segment' import { RoofSegmentNode } from './roof-segment'
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
export type RoofSurfaceMaterialSpec = {
material?: MaterialSchema
materialPreset?: string
}
export const RoofNode = BaseNode.extend({ export const RoofNode = BaseNode.extend({
id: objectId('roof'), id: objectId('roof'),
type: nodeType('roof'), type: nodeType('roof'),
material: MaterialSchema.optional(), material: MaterialSchemaSchema.optional(),
materialPreset: z.string().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]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
@@ -26,3 +38,66 @@ export const RoofNode = BaseNode.extend({
) )
export type RoofNode = z.infer<typeof RoofNode> 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 dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import { StairSegmentNode } from './stair-segment' import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) 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 StairType = z.infer<typeof StairType>
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode> export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode> 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({ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
type: nodeType('stair'), type: nodeType('stair'),
material: MaterialSchema.optional(), material: MaterialSchemaSchema.optional(),
materialPreset: z.string().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]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
@@ -71,3 +82,74 @@ export const StairNode = BaseNode.extend({
) )
export type StairNode = z.infer<typeof StairNode> 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 children: z
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
.default([]), .default([]),
// Legacy single-material wall finish. Read for backward compatibility only.
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
// Legacy single-material wall finish preset. Read for backward compatibility only.
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
interiorMaterial: MaterialSchema.optional(),
interiorMaterialPreset: z.string().optional(),
exteriorMaterial: MaterialSchema.optional(),
exteriorMaterialPreset: z.string().optional(),
thickness: z.number().optional(), thickness: z.number().optional(),
height: z.number().optional(), height: z.number().optional(),
curveOffset: z.number().optional(), curveOffset: z.number().optional(),
@@ -37,3 +43,62 @@ export const WallNode = BaseNode.extend({
`, `,
) )
export type WallNode = z.infer<typeof WallNode> export type WallNode = z.infer<typeof WallNode>
export type WallSurfaceSide = 'interior' | 'exterior'
export type WallSurfaceMaterialSpec = {
material?: z.infer<typeof MaterialSchema>
materialPreset?: string
}
type WallSurfaceMaterialSource = {
material?: z.infer<typeof MaterialSchema>
materialPreset?: string
interiorMaterial?: z.infer<typeof MaterialSchema>
interiorMaterialPreset?: string
exteriorMaterial?: z.infer<typeof MaterialSchema>
exteriorMaterialPreset?: string
}
function getConfiguredWallSurfaceMaterial(
wall: WallSurfaceMaterialSource,
side: WallSurfaceSide,
): WallSurfaceMaterialSpec {
if (side === 'interior') {
return {
material: wall.interiorMaterial,
materialPreset: wall.interiorMaterialPreset,
}
}
return {
material: wall.exteriorMaterial,
materialPreset: wall.exteriorMaterialPreset,
}
}
function hasSurfaceMaterial(spec: WallSurfaceMaterialSpec): boolean {
return spec.material !== undefined || typeof spec.materialPreset === 'string'
}
export function getEffectiveWallSurfaceMaterial(
wall: WallSurfaceMaterialSource,
side: WallSurfaceSide,
): WallSurfaceMaterialSpec {
const configured = getConfiguredWallSurfaceMaterial(wall, side)
if (hasSurfaceMaterial(configured)) {
return configured
}
return {
material: wall.material,
materialPreset: wall.materialPreset,
}
}
export function getWallSurfaceMaterialSignature(spec: WallSurfaceMaterialSpec): string {
return JSON.stringify({
material: spec.material ?? null,
materialPreset: spec.materialPreset ?? null,
})
}
+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 { CollectionId } from '../../schema/collections'
import type { SceneState } from '../use-scene' import type { SceneState } from '../use-scene'
@@ -17,11 +23,7 @@ type WallMergePlan = {
let pendingRafId: number | null = null let pendingRafId: number | null = null
let pendingUpdates: Set<AnyNodeId> = new Set() let pendingUpdates: Set<AnyNodeId> = new Set()
function pointsEqual( function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) {
a: [number, number],
b: [number, number],
tolerance = 1e-6,
) {
const dx = a[0] - b[0] const dx = a[0] - b[0]
const dz = a[1] - b[1] const dz = a[1] - b[1]
return dx * dx + dz * dz <= tolerance * tolerance return dx * dx + dz * dz <= tolerance * tolerance
@@ -40,32 +42,30 @@ function getWallEndpointAtPoint(
return null return null
} }
function getWallFreeEndpoint( function getWallFreeEndpoint(wall: Pick<WallNode, 'start' | 'end'>, sharedPoint: [number, number]) {
wall: Pick<WallNode, 'start' | 'end'>,
sharedPoint: [number, number],
) {
return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start
} }
function areWallStylesCompatible(a: WallNode, b: WallNode) { function areWallStylesCompatible(a: WallNode, b: WallNode) {
const aInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'interior'))
const bInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'interior'))
const aExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'exterior'))
const bExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'exterior'))
return ( return (
(a.parentId ?? null) === (b.parentId ?? null) && (a.parentId ?? null) === (b.parentId ?? null) &&
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
a.materialPreset === b.materialPreset && aInterior === bInterior &&
JSON.stringify(a.material ?? null) === JSON.stringify(b.material ?? null) && aExterior === bExterior &&
a.frontSide === b.frontSide && a.frontSide === b.frontSide &&
a.backSide === b.backSide && a.backSide === b.backSide &&
a.visible === b.visible a.visible === b.visible
) )
} }
function areWallsCollinearAcrossPoint( function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) {
a: WallNode,
b: WallNode,
sharedPoint: [number, number],
) {
const freeA = getWallFreeEndpoint(a, sharedPoint) const freeA = getWallFreeEndpoint(a, sharedPoint)
const freeB = getWallFreeEndpoint(b, sharedPoint) const freeB = getWallFreeEndpoint(b, sharedPoint)
const ax = freeA[0] - sharedPoint[0] const ax = freeA[0] - sharedPoint[0]
@@ -111,7 +111,10 @@ function buildMergedWallAttachmentUpdates(
mergedEnd: [number, number], mergedEnd: [number, number],
nodes: Record<AnyNodeId, AnyNode>, nodes: Record<AnyNodeId, AnyNode>,
): WallAttachmentUpdate[] { ): WallAttachmentUpdate[] {
const mergedLength = Math.max(Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), 1e-6) const mergedLength = Math.max(
Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]),
1e-6,
)
const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength
const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength
const updates: WallAttachmentUpdate[] = [] const updates: WallAttachmentUpdate[] = []
@@ -126,11 +129,16 @@ function buildMergedWallAttachmentUpdates(
const sourceWall = child.parentId === secondary.id ? secondary : primary const sourceWall = child.parentId === secondary.id ? secondary : primary
const sourceLength = Math.max(wallLength(sourceWall), 1e-6) const sourceLength = Math.max(wallLength(sourceWall), 1e-6)
const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 const localX = typeof child.position[0] === 'number' ? child.position[0] : 0
const worldX = sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength const worldX =
const worldZ = sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength
const worldZ =
sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength
const nextLocalX = Math.max( const nextLocalX = Math.max(
0, 0,
Math.min(mergedLength, (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ), Math.min(
mergedLength,
(worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ,
),
) )
updates.push({ updates.push({
+190 -1
View File
@@ -100,6 +100,187 @@ function normalizeStairSegmentNode(node: Record<string, unknown>) {
return parsed.success ? parsed.data : null return parsed.success ? parsed.data : null
} }
function migrateWallSurfaceMaterials(node: Record<string, any>) {
const hasInterior =
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
const hasExterior =
node.exteriorMaterial !== undefined || typeof node.exteriorMaterialPreset === 'string'
const legacyFinish = {
material: node.material,
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
}
if (!hasInterior && !hasExterior) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
return {
...node,
interiorMaterial: legacyFinish.material,
interiorMaterialPreset: legacyFinish.materialPreset,
exteriorMaterial: legacyFinish.material,
exteriorMaterialPreset: legacyFinish.materialPreset,
}
}
if (!hasInterior) {
return {
...node,
interiorMaterial: node.exteriorMaterial,
interiorMaterialPreset: node.exteriorMaterialPreset,
}
}
if (!hasExterior) {
return {
...node,
exteriorMaterial: node.interiorMaterial,
exteriorMaterialPreset: node.interiorMaterialPreset,
}
}
return node
}
function 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> { function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
const patchedNodes = { ...nodes } const patchedNodes = { ...nodes }
for (const [id, node] of Object.entries(patchedNodes)) { for (const [id, node] of Object.entries(patchedNodes)) {
@@ -141,7 +322,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
} }
if (node.type === 'stair') { if (node.type === 'stair') {
const normalized = normalizeStairNode(node) const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) { if (normalized) {
patchedNodes[id] = normalized patchedNodes[id] = normalized
} }
@@ -153,6 +334,14 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
patchedNodes[id] = normalized 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> return patchedNodes as Record<string, AnyNode>
} }
@@ -20,26 +20,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
let minX = Number.POSITIVE_INFINITY let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY
let minZ = 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) { for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index) minX = Math.min(minX, position.getX(index))
const py = position.getY(index) minY = Math.min(minY, position.getY(index))
const pz = position.getZ(index) minZ = Math.min(minZ, 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)
} }
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) { for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index) const px = position.getX(index)
const py = position.getY(index) const py = position.getY(index)
@@ -52,14 +39,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
let v = 0 let v = 0
if (ny >= nx && ny >= nz) { if (ny >= nx && ny >= nz) {
u = (px - minX) / width u = px - minX
v = (pz - minZ) / depth v = pz - minZ
} else if (nx >= nz) { } else if (nx >= nz) {
u = (pz - minZ) / depth u = pz - minZ
v = (py - minY) / height v = py - minY
} else { } else {
u = (px - minX) / width u = px - minX
v = (py - minY) / height v = py - minY
} }
uvs[index * 2] = u uvs[index * 2] = u
@@ -157,13 +144,17 @@ function generateFenceGeometry(fence: FenceNode) {
const geometries = parts.map((part) => { const geometries = parts.map((part) => {
const geometry = new THREE.BoxGeometry(1, 1, 1) const geometry = new THREE.BoxGeometry(1, 1, 1)
geometry.scale(part.scale[0], part.scale[1], part.scale[2]) geometry.scale(part.scale[0], part.scale[1], part.scale[2])
applyFenceUVs(geometry)
geometry.translate(part.position[0], part.position[1], part.position[2]) geometry.translate(part.position[0], part.position[1], part.position[2])
return geometry return geometry
}) })
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose()) 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() merged.computeVertexNormals()
return merged return merged
} }
+38 -1
View File
@@ -11,7 +11,7 @@ import useScene from '../../store/use-scene'
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true csgEvaluator.useGroups = true
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash ;(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) { function prepareBrushForCSG(brush: Brush) {
brush.geometry.computeBoundsTree = computeBoundsTree brush.geometry.computeBoundsTree = computeBoundsTree
@@ -25,6 +25,7 @@ const _position = new THREE.Vector3()
const _quaternion = new THREE.Quaternion() const _quaternion = new THREE.Quaternion()
const _scale = new THREE.Vector3(1, 1, 1) const _scale = new THREE.Vector3(1, 1, 1)
const _yAxis = new THREE.Vector3(0, 1, 0) const _yAxis = new THREE.Vector3(0, 1, 0)
const _uvFaceNormal = new THREE.Vector3()
// Pending merged-roof updates carried across frames (for throttling) // Pending merged-roof updates carried across frames (for throttling)
const pendingRoofUpdates = new Set<AnyNodeId>() const pendingRoofUpdates = new Set<AnyNodeId>()
@@ -251,6 +252,7 @@ function updateMergedRoofGeometry(
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
} }
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals() resultGeo.computeVertexNormals()
mergedMesh.geometry.dispose() mergedMesh.geometry.dispose()
mergedMesh.geometry = resultGeo mergedMesh.geometry = resultGeo
@@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
wallBrush.geometry.dispose() wallBrush.geometry.dispose()
innerBrush.geometry.dispose() innerBrush.geometry.dispose()
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals() resultGeo.computeVertexNormals()
return resultGeo return resultGeo
} }
@@ -936,6 +939,7 @@ function createGeometryFromFaces(
): THREE.BufferGeometry { ): THREE.BufferGeometry {
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = []
const indices: number[] = [] const indices: number[] = []
const groups: { start: number; count: number; materialIndex: number }[] = [] const groups: { start: number; count: number; materialIndex: number }[] = []
let vertexCount = 0 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)
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) indices.push(vertexCount, vertexCount + 1, vertexCount + 2)
faceVertexCount += 3 faceVertexCount += 3
@@ -990,6 +998,7 @@ function createGeometryFromFaces(
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setIndex(indices) geometry.setIndex(indices)
for (const g of groups) { for (const g of groups) {
@@ -999,6 +1008,34 @@ function createGeometryFromFaces(
// Merge identical vertices to optimize geometry for CSG and create clean topology // Merge identical vertices to optimize geometry for CSG and create clean topology
const mergedGeo = mergeVertices(geometry, 1e-4) const mergedGeo = mergeVertices(geometry, 1e-4)
geometry.dispose() geometry.dispose()
ensureUv2Attribute(mergedGeo)
return 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 pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2 const MAX_STAIRS_PER_FRAME = 2
const MAX_SEGMENTS_PER_FRAME = 4 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 // STAIR SYSTEM
@@ -198,7 +202,7 @@ function generateStairSegmentGeometry(
shape.lineTo(0, 0) shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, { const extrudedGeometry = new THREE.ExtrudeGeometry(shape, {
steps: 1, steps: 1,
depth: width, depth: width,
bevelEnabled: false, bevelEnabled: false,
@@ -209,7 +213,16 @@ function generateStairSegmentGeometry(
const matrix = new THREE.Matrix4() const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2) matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(width / 2, 0, 0) 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 return geometry
} }
@@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
const absoluteHeight = computeAbsoluteHeight(node) const absoluteHeight = computeAbsoluteHeight(node)
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight) const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
applyStraightStairMaterialGroups(newGeometry)
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = newGeometry mesh.geometry = newGeometry
@@ -363,6 +377,7 @@ function updateMergedStairGeometry(
} }
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry() const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
applyStraightStairMaterialGroups(merged)
replaceMeshGeometry(mergedMesh, merged) replaceMeshGeometry(mergedMesh, merged)
// Dispose individual geometries // 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 // SEGMENT CHAINING
// ============================================================================ // ============================================================================
@@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
function createEmptyGeometry(): THREE.BufferGeometry { function createEmptyGeometry(): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) 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 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 { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import { import {
calculateLevelMiters, calculateLevelMiters,
getAdjacentWallIds, getAdjacentWallIds,
getWallMiterBoundaryPoints, getWallMiterBoundaryPoints,
type Point2D, type Point2D,
type WallMiterData,
pointToKey, pointToKey,
type WallMiterData,
} from './wall-mitering' } from './wall-mitering'
// Reusable CSG evaluator for better performance // Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
const WALL_FACE_NORMAL_Y_EPSILON = 0.6
const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003
type WallBoundaryEdgeTag = 'front' | 'back' | 'base'
type TaggedWallBoundaryEdge = {
start: THREE.Vector2
end: THREE.Vector2
tag: WallBoundaryEdgeTag
}
function ensureUv2Attribute(geometry: THREE.BufferGeometry) { function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv') const uv = geometry.getAttribute('uv')
@@ -78,6 +88,207 @@ function insetCurvedWallBoundaryPointsFor3D(
return next return next
} }
function addTaggedWallBoundaryEdge(
edges: TaggedWallBoundaryEdge[],
points: { x: number; z: number }[],
startIndex: number,
endIndex: number,
tag: WallBoundaryEdgeTag,
) {
const start = points[startIndex]
const end = points[endIndex]
if (!(start && end)) return
if (Math.hypot(end.x - start.x, end.z - start.z) < 1e-6) return
edges.push({
start: new THREE.Vector2(start.x, start.z),
end: new THREE.Vector2(end.x, end.z),
tag,
})
}
function buildTaggedWallBoundaryEdges(
wall: WallNode,
localPoints: { x: number; z: number }[],
miterData: WallMiterData,
): TaggedWallBoundaryEdge[] {
if (localPoints.length < 2) return []
const edges: TaggedWallBoundaryEdge[] = []
if (isCurvedWall(wall)) {
const sidePointCount = Math.floor(localPoints.length / 2)
if (sidePointCount < 2) return edges
for (let index = 0; index < sidePointCount - 1; index += 1) {
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'back')
}
addTaggedWallBoundaryEdge(edges, localPoints, sidePointCount - 1, sidePointCount, 'base')
for (let index = sidePointCount; index < localPoints.length - 1; index += 1) {
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'front')
}
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
return edges
}
const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] })
const startJunction = miterData.junctionData.get(startKey)?.get(wall.id)
const startLeftIndex = startJunction ? localPoints.length - 2 : localPoints.length - 1
const endLeftIndex = startJunction ? localPoints.length - 3 : localPoints.length - 2
addTaggedWallBoundaryEdge(edges, localPoints, 0, 1, 'back')
for (let index = 1; index < endLeftIndex; index += 1) {
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
}
addTaggedWallBoundaryEdge(edges, localPoints, endLeftIndex, startLeftIndex, 'front')
for (let index = startLeftIndex; index < localPoints.length - 1; index += 1) {
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
}
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
return edges
}
function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBoundaryEdge): number {
const edgeDx = edge.end.x - edge.start.x
const edgeDz = edge.end.y - edge.start.y
const pointDx = point.x - edge.start.x
const pointDz = point.y - edge.start.y
const edgeLengthSq = edgeDx * edgeDx + edgeDz * edgeDz
if (edgeLengthSq < 1e-12) {
return point.distanceTo(edge.start)
}
const t = THREE.MathUtils.clamp((pointDx * edgeDx + pointDz * edgeDz) / edgeLengthSq, 0, 1)
const closestX = edge.start.x + edgeDx * t
const closestZ = edge.start.y + edgeDz * t
return Math.hypot(point.x - closestX, point.y - closestZ)
}
function getWallFaceMaterialIndex(
wall: Pick<WallNode, 'frontSide' | 'backSide'>,
face: 'front' | 'back',
): 0 | 1 | 2 {
const semantic = face === 'front' ? wall.frontSide : wall.backSide
const fallback = face === 'front' ? 1 : 2
if (semantic === 'interior') return 1
if (semantic === 'exterior') return 2
return fallback
}
function assignWallMaterialGroups(
geometry: THREE.BufferGeometry,
wall: WallNode,
boundaryEdges: TaggedWallBoundaryEdge[],
) {
const position = geometry.getAttribute('position')
if (!position) return
const index = geometry.getIndex()
const triangleCount = index ? Math.floor(index.count / 3) : Math.floor(position.count / 3)
if (triangleCount === 0) {
geometry.clearGroups()
return
}
const triangleMaterials = new Array<number>(triangleCount).fill(0)
const a = new THREE.Vector3()
const b = new THREE.Vector3()
const c = new THREE.Vector3()
const ab = new THREE.Vector3()
const ac = new THREE.Vector3()
const normal = new THREE.Vector3()
const centroid = new THREE.Vector3()
const projectedCentroid = new THREE.Vector2()
const maxBoundaryDistance = Math.max(
getWallThickness(wall) * 0.02,
WALL_FACE_EDGE_DISTANCE_EPSILON,
)
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
const baseIndex = triangleIndex * 3
const ia = index ? index.getX(baseIndex) : baseIndex
const ib = index ? index.getX(baseIndex + 1) : baseIndex + 1
const ic = index ? index.getX(baseIndex + 2) : baseIndex + 2
a.fromBufferAttribute(position, ia)
b.fromBufferAttribute(position, ib)
c.fromBufferAttribute(position, ic)
ab.subVectors(b, a)
ac.subVectors(c, a)
normal.crossVectors(ab, ac)
if (normal.lengthSq() < 1e-12) {
triangleMaterials[triangleIndex] = 0
continue
}
normal.normalize()
if (Math.abs(normal.y) >= WALL_FACE_NORMAL_Y_EPSILON) {
triangleMaterials[triangleIndex] = 0
continue
}
centroid
.copy(a)
.add(b)
.add(c)
.multiplyScalar(1 / 3)
projectedCentroid.set(centroid.x, centroid.z)
let nearestTag: WallBoundaryEdgeTag | null = null
let nearestDistance = Number.POSITIVE_INFINITY
for (const edge of boundaryEdges) {
const distance = distanceToWallBoundaryEdge(projectedCentroid, edge)
if (distance < nearestDistance) {
nearestDistance = distance
nearestTag = edge.tag
}
}
if (!nearestTag || nearestDistance > maxBoundaryDistance) {
triangleMaterials[triangleIndex] = 0
continue
}
if (nearestTag === 'base') {
triangleMaterials[triangleIndex] = 0
continue
}
triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag)
}
geometry.clearGroups()
let currentMaterial = triangleMaterials[0] ?? 0
let groupStart = 0
for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) {
const materialIndex = triangleMaterials[triangleIndex] ?? 0
if (materialIndex === currentMaterial) continue
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
groupStart = triangleIndex
currentMaterial = materialIndex
}
geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial)
}
// ============================================================================ // ============================================================================
// WALL SYSTEM // WALL SYSTEM
// ============================================================================ // ============================================================================
@@ -252,6 +463,7 @@ export function generateExtrudedWall(
// Convert polygon to local coordinates // Convert polygon to local coordinates
const localPoints = polyPoints.map(worldToLocal) const localPoints = polyPoints.map(worldToLocal)
const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData)
// Build THREE.js shape // Build THREE.js shape
// Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
@@ -272,6 +484,7 @@ export function generateExtrudedWall(
// Rotate so extrusion direction (Z) becomes height direction (Y) // Rotate so extrusion direction (Z) becomes height direction (Y)
geometry.rotateX(-Math.PI / 2) geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals() geometry.computeVertexNormals()
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
ensureUv2Attribute(geometry) ensureUv2Attribute(geometry)
// Apply CSG subtraction for cutouts (doors/windows) // Apply CSG subtraction for cutouts (doors/windows)
@@ -307,6 +520,7 @@ export function generateExtrudedWall(
const resultGeometry = resultBrush.geometry const resultGeometry = resultBrush.geometry
resultGeometry.computeVertexNormals() resultGeometry.computeVertexNormals()
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
ensureUv2Attribute(resultGeometry) ensureUv2Attribute(resultGeometry)
return resultGeometry return resultGeometry
@@ -5,16 +5,24 @@ import {
emitter, emitter,
type ItemNode, type ItemNode,
type NodeEvent, type NodeEvent,
type RoofEvent,
type RoofSegmentEvent,
resolveLevelId, resolveLevelId,
sceneRegistry, sceneRegistry,
type StairEvent,
type StairNode,
type StairSurfaceMaterialRole,
type StairSegmentEvent,
useScene, useScene,
type WallEvent,
type WallSurfaceSide,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' 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 { 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' import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => { const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -68,6 +76,123 @@ export const resolveBuildingId = (
return null 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 = { const HIGHLIGHT_PROFILES = {
delete: { delete: {
color: new Color('#dc2626'), color: new Color('#dc2626'),
@@ -439,6 +564,42 @@ export const SelectionManager = () => {
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current) 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 // Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => { setTimeout(() => {
clickHandledRef.current = false clickHandledRef.current = false
@@ -471,6 +632,7 @@ export const SelectionManager = () => {
const { phase, structureLayer } = useEditor.getState() const { phase, structureLayer } = useEditor.getState()
const activeStrategy = SELECTION_STRATEGIES[phase] const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect() if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedMaterialTarget(null)
// When deselecting from zone mode, return to structure select // When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') { if (phase === 'structure' && structureLayer === 'zones') {
@@ -704,6 +866,12 @@ export const SelectionManager = () => {
} }
const SelectionStateSync = () => { 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(() => { useEffect(() => {
return useScene.subscribe((state) => { return useScene.subscribe((state) => {
const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
@@ -732,6 +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 return null
} }
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
* Imperatively toggles the Three.js visibility of roof objects based on the * Imperatively toggles the Three.js visibility of roof objects based on the
* editor selection — without causing React re-renders in RoofRenderer. * 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 * - merged-roof mesh is hidden
* - segments-wrapper group is shown (individual segments visible for editing) * - segments-wrapper group is shown (individual segments visible for editing)
* - all children are marked dirty so RoofSystem rebuilds their geometry * - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -22,14 +22,14 @@ export const RoofEditSystem = () => {
useEffect(() => { useEffect(() => {
const nodes = useScene.getState().nodes 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>() const activeRoofIds = new Set<string>()
for (const id of selectedIds) { for (const id of selectedIds) {
const node = nodes[id as AnyNodeId] const node = nodes[id as AnyNodeId]
if (!node) continue if (!node) continue
if (node.type === 'roof') { if (node.type === 'roof-segment' && node.parentId) {
activeRoofIds.add(id)
} else if (node.type === 'roof-segment' && node.parentId) {
activeRoofIds.add(node.parentId) activeRoofIds.add(node.parentId)
} }
} }
@@ -6,7 +6,7 @@ import {
type MaterialSchema, type MaterialSchema,
type MaterialTarget, type MaterialTarget,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useState } from 'react' import { useEffect, useState } from 'react'
type MaterialPickerProps = { type MaterialPickerProps = {
nodeType?: MaterialTarget nodeType?: MaterialTarget
@@ -14,6 +14,8 @@ type MaterialPickerProps = {
selectedMaterialPreset?: string selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void onSelectMaterialPreset?: (materialPreset: string) => void
hideSideControl?: boolean
disabled?: boolean
} }
export function MaterialPicker({ export function MaterialPicker({
@@ -22,10 +24,16 @@ export function MaterialPicker({
selectedMaterialPreset, selectedMaterialPreset,
onChange, onChange,
onSelectMaterialPreset, onSelectMaterialPreset,
hideSideControl = false,
disabled = false,
}: MaterialPickerProps) { }: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties) const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : [] const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
useEffect(() => {
setShowCustom(!!value?.properties && !selectedMaterialPreset)
}, [selectedMaterialPreset, value?.properties])
const currentProps = value?.properties || { const currentProps = value?.properties || {
color: '#ffffff', color: '#ffffff',
roughness: 0.5, roughness: 0.5,
@@ -38,11 +46,13 @@ export function MaterialPicker({
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined) selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handleCatalogSelect = (materialId: string) => { const handleCatalogSelect = (materialId: string) => {
if (disabled) return
setShowCustom(false) setShowCustom(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
} }
const handleCustomOpen = () => { const handleCustomOpen = () => {
if (disabled) return
setShowCustom(true) setShowCustom(true)
onChange?.({ onChange?.({
preset: 'custom', preset: 'custom',
@@ -61,6 +71,7 @@ export function MaterialPicker({
prop: keyof typeof currentProps, prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps], val: (typeof currentProps)[keyof typeof currentProps],
) => { ) => {
if (disabled) return
onChange?.({ onChange?.({
preset: 'custom', preset: 'custom',
properties: { properties: {
@@ -71,7 +82,7 @@ export function MaterialPicker({
} }
return ( return (
<div className="space-y-3"> <div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && ( {(catalogItems.length > 0 || onChange) && (
<div className="space-y-2"> <div className="space-y-2">
{catalogItems.length > 0 ? ( {catalogItems.length > 0 ? (
@@ -193,6 +204,7 @@ export function MaterialPicker({
</span> </span>
</div> </div>
{!hideSideControl && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Side</label> <label className="w-16 text-gray-500 text-xs">Side</label>
<select <select
@@ -207,6 +219,7 @@ export function MaterialPicker({
<option value="double">Double</option> <option value="double">Double</option>
</select> </select>
</div> </div>
)}
</div> </div>
)} )}
</div> </div>
@@ -21,6 +21,20 @@ function stepPrecision(s: number): number {
return Math.max(0, Math.ceil(-Math.log10(s))) 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({ export function SliderControl({
label, label,
value, value,
@@ -58,16 +72,14 @@ export function SliderControl({
if (isEditing) return if (isEditing) return
e.preventDefault() e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1 const direction = e.deltaY < 0 ? 1 : -1
let s = step const s = getAdjustedStep(step, e)
if (e.shiftKey) s = step * 10
else if (e.altKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s) const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final) if (final !== valueRef.current) onChange(final)
} }
el.addEventListener('wheel', handleWheel, { passive: false }) el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel) return () => el.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision]) }, [isEditing, step, clamp, onChange])
// Arrow key support while hovered // Arrow key support while hovered
useEffect(() => { useEffect(() => {
@@ -78,9 +90,7 @@ export function SliderControl({
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1 else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
if (direction !== 0) { if (direction !== 0) {
e.preventDefault() e.preventDefault()
let s = step const s = getAdjustedStep(step, e)
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s) const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final) if (final !== valueRef.current) onChange(final)
@@ -88,7 +98,7 @@ export function SliderControl({
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision]) }, [isHovered, isEditing, step, clamp, onChange])
const handleLabelPointerDown = useCallback( const handleLabelPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
@@ -107,16 +117,14 @@ export function SliderControl({
if (!dragRef.current) return if (!dragRef.current) return
const { startX, startValue } = dragRef.current const { startX, startValue } = dragRef.current
const dx = e.clientX - startX const dx = e.clientX - startX
let s = step const s = getAdjustedStep(step, e)
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
// 4 px per step at default sensitivity // 4 px per step at default sensitivity
const newValue = clamp( const newValue = clamp(
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))), Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
) )
onChange(newValue) onChange(newValue)
}, },
[step, precision, clamp, onChange], [step, clamp, onChange],
) )
const handleLabelPointerUp = useCallback( const handleLabelPointerUp = useCallback(
@@ -163,12 +171,18 @@ export function SliderControl({
setIsEditing(false) setIsEditing(false)
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
const newV = clamp(value + step) const adjustedStep = getAdjustedStep(step, e)
const newV = clamp(
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV) onChange(newV)
setInputValue(newV.toFixed(precision)) setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
const newV = clamp(value - step) const adjustedStep = getAdjustedStep(step, e)
const newV = clamp(
Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV) onChange(newV)
setInputValue(newV.toFixed(precision)) setInputValue(newV.toFixed(precision))
} }
@@ -3,8 +3,10 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
type MaterialSchema, type MaterialSchema,
type RoofNode, type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema, RoofNode as RoofNodeSchema,
type RoofSegmentNode, type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema, RoofSegmentNode as RoofSegmentNodeSchema,
@@ -22,12 +24,39 @@ import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function 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() { export function RoofPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -50,18 +79,31 @@ export function RoofPanel() {
[selectedId, updateNode], [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) => { (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) => { (materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined }) if (!node || !materialTargetRole) return
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
}, },
[handleUpdate], [handleUpdate, materialTargetRole, node],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -170,11 +212,13 @@ export function RoofPanel() {
</button> </button>
))} ))}
</div> </div>
<ActionGroup>
<ActionButton <ActionButton
icon={<Plus className="h-3.5 w-3.5" />} icon={<Plus className="h-3.5 w-3.5" />}
label="Add Segment" label="Add Segment"
onClick={handleAddSegment} onClick={handleAddSegment}
/> />
</ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Position"> <PanelSection title="Position">
@@ -267,12 +311,19 @@ export function RoofPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <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 <MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="roof" nodeType="roof"
onChange={handleMaterialChange} onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange} onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={node.materialPreset} selectedMaterialPreset={materialPickerValue.materialPreset}
value={node.material} value={materialPickerValue.material}
/> />
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
@@ -3,10 +3,12 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveStairSurfaceMaterial,
type LevelNode, type LevelNode,
type MaterialSchema, type MaterialSchema,
type StairNode, type StairNode,
type StairRailingMode, type StairRailingMode,
type StairSurfaceMaterialRole,
type StairSlabOpeningMode, type StairSlabOpeningMode,
type StairTopLandingMode, type StairTopLandingMode,
type StairType, type StairType,
@@ -31,6 +33,32 @@ import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control' import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' 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 }[] = [ const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
{ label: 'None', value: 'none' }, { label: 'None', value: 'none' },
{ label: 'Left', value: 'left' }, { label: 'Left', value: 'left' },
@@ -62,6 +90,7 @@ export function StairPanel() {
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes) const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -92,18 +121,31 @@ export function StairPanel() {
[selectedId, updateNode], [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) => { (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) => { (materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined }) if (!node || !materialTargetRole) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
}, },
[handleUpdate], [handleUpdate, materialTargetRole, node],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -569,12 +611,19 @@ export function StairPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <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 <MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="stair" nodeType="stair"
onChange={handleMaterialChange} onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange} onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={node.materialPreset} selectedMaterialPreset={materialPickerValue.materialPreset}
value={node.material} value={materialPickerValue.material}
/> />
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
@@ -3,17 +3,20 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveWallSurfaceMaterial,
getClampedWallCurveOffset, getClampedWallCurveOffset,
getMaxWallCurveOffset, getMaxWallCurveOffset,
getWallCurveLength, getWallCurveLength,
getWallSurfaceMaterialSignature,
normalizeWallCurveOffset, normalizeWallCurveOffset,
type MaterialSchema, type MaterialSchema,
useScene, useScene,
type WallSurfaceSide,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react' import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react' import { useCallback, useMemo } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -22,12 +25,39 @@ import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide | null,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === null || targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === null || targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function WallPanel() { export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -58,6 +88,35 @@ export function WallPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const effectiveInteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
[node],
)
const effectiveExteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
[node],
)
const surfaceMaterialsMatch = useMemo(
() =>
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
[effectiveExteriorMaterial, effectiveInteriorMaterial],
)
const materialTargetSide =
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( const handleUpdateLength = useCallback(
(newLength: number) => { (newLength: number) => {
if (!node || newLength <= 0) return if (!node || newLength <= 0) return
@@ -83,16 +142,18 @@ export function WallPanel() {
const handleMaterialPresetChange = useCallback( const handleMaterialPresetChange = useCallback(
(materialPreset: string) => { (materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined }) if (!node || !materialTargetSide) return
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
}, },
[handleUpdate], [handleUpdate, materialTargetSide, node],
) )
const handleCustomMaterialChange = useCallback( const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined }) if (!node || !materialTargetSide) return
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
}, },
[handleUpdate], [handleUpdate, materialTargetSide, node],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -169,7 +230,7 @@ export function WallPanel() {
min={-Math.max(0.01, maxCurveOffset)} min={-Math.max(0.01, maxCurveOffset)}
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })} onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
precision={2} precision={2}
step={0.01} step={0.1}
unit="m" unit="m"
value={Math.round(curveOffset * 100) / 100} value={Math.round(curveOffset * 100) / 100}
/> />
@@ -177,15 +238,23 @@ export function WallPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
{!materialTargetSide ? (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the wall face you want to edit. Materials now apply to one side at a time.
</div>
) : null}
<MaterialPicker <MaterialPicker
disabled={!materialTargetSide}
hideSideControl
nodeType="wall" nodeType="wall"
onChange={handleCustomMaterialChange} onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange} onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset} selectedMaterialPreset={materialPickerValue.materialPreset}
value={node.material} value={materialPickerValue.material}
/> />
</PanelSection> </PanelSection>
<PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && ( {!hasWallChildrenBlockingCurve && (
@@ -196,6 +265,7 @@ export function WallPanel() {
/> />
)} )}
</ActionGroup> </ActionGroup>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
+16 -1
View File
@@ -1,21 +1,25 @@
'use client' 'use client'
import type { AssetInput } from '@pascal-app/core'
import { import {
type AnyNodeId,
type AssetInput,
type BuildingNode, type BuildingNode,
type CeilingNode, type CeilingNode,
type DoorNode, type DoorNode,
type FenceNode, type FenceNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type RoofSurfaceMaterialRole,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type SlabNode, type SlabNode,
type Space, type Space,
type StairSurfaceMaterialRole,
type StairNode, type StairNode,
type StairSegmentNode, type StairSegmentNode,
useScene, useScene,
type WallNode, type WallNode,
type WallSurfaceSide,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -80,6 +84,13 @@ export type MovingWallEndpoint = {
endpoint: 'start' | 'end' endpoint: 'start' | 'end'
} }
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
export type SelectedMaterialTarget = {
nodeId: AnyNodeId
role: MaterialTargetRole
}
type EditorState = { type EditorState = {
phase: Phase phase: Phase
setPhase: (phase: Phase) => void setPhase: (phase: Phase) => void
@@ -127,6 +138,8 @@ type EditorState = {
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
curvingWall: WallNode | null curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void setCurvingWall: (wall: WallNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode // Space detection for cutaway mode
@@ -502,6 +515,8 @@ const useEditor = create<EditorState>()(
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }), setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
curvingWall: null, curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }), setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {}, spaces: {},
@@ -1,9 +1,9 @@
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
@@ -16,16 +16,22 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const parentNode = const parentNode =
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined 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 customMaterial = useMemo(() => {
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset if (node.material !== undefined || typeof node.materialPreset === 'string') {
const effectiveMaterial = node.material ?? parentNode?.material return null
}
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset) return parentNode ? getRoofMaterialArray(parentNode) : null
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return null
return createMaterial(mat)
}, [ }, [
node.materialPreset, node.materialPreset,
node.material, node.material,
@@ -37,21 +43,31 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
parentNode?.material?.preset, parentNode?.material?.preset,
parentNode?.material?.properties, parentNode?.material?.properties,
parentNode?.material?.texture, parentNode?.material?.texture,
parentNode?.topMaterial,
parentNode?.topMaterialPreset,
parentNode?.edgeMaterial,
parentNode?.edgeMaterialPreset,
parentNode?.wallMaterial,
parentNode?.wallMaterialPreset,
]) ])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return ( return (
<mesh <mesh
geometry={placeholderGeometry}
material={material} material={material}
position={node.position} position={node.position}
ref={ref} ref={ref}
rotation-y={node.rotation} rotation-y={node.rotation}
visible={node.visible} visible={node.visible}
{...handlers} {...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 { type RoofNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
import { roofDebugMaterials, roofMaterials } from './roof-materials' import { roofDebugMaterials, roofMaterials } from './roof-materials'
@@ -14,17 +14,41 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const handlers = useNodeEvents(node, 'roof') const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors) 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 customMaterial = useMemo(
const presetMaterial = createMaterialFromPresetRef(node.materialPreset) () => getRoofMaterialArray(node),
if (presetMaterial) return presetMaterial [
const mat = node.material node.materialPreset,
if (!mat) return null node.material,
return createMaterial(mat) node.material?.preset,
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) node.material?.properties,
node.material?.texture,
node.topMaterial,
node.topMaterialPreset,
node.edgeMaterial,
node.edgeMaterialPreset,
node.wallMaterial,
node.wallMaterialPreset,
],
)
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return ( return (
<group <group
position={node.position} position={node.position}
@@ -33,9 +57,13 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> >
<mesh castShadow material={material} name="merged-roof" receiveShadow> <mesh
<boxGeometry args={[0, 0, 0]} /> castShadow
</mesh> geometry={placeholderGeometry}
material={material}
name="merged-roof"
receiveShadow
/>
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
@@ -55,7 +55,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
}) })
const next = nodeList 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) => { .filter((n) => {
if (!Number.isFinite(lowestLevelIndex)) return true if (!Number.isFinite(lowestLevelIndex)) return true
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined 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 { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { import {
applyMaterialPresetToMaterials,
createMaterial, createMaterial,
createMaterialFromPresetRef,
DEFAULT_SLAB_MATERIAL, DEFAULT_SLAB_MATERIAL,
} from '../../../lib/materials' } from '../../../lib/materials'
@@ -17,9 +17,18 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const handlers = useNodeEvents(node, 'slab') const handlers = useNodeEvents(node, 'slab')
const material = useMemo(() => { const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset) const preset = getMaterialPresetByRef(node.materialPreset)
const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL) const slabMaterial = preset
const slabMaterial = sourceMaterial.clone() ? 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 // Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
// pipeline variants that can fail when geometry is regenerated while a // 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 { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' 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 }) => { export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!) 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 node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
const material = useMemo(() => { const material = useMemo(() => {
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset return getStraightStairSegmentBodyMaterials(node, parentNode)
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)
}, [ }, [
node.materialPreset, node.materialPreset,
node.material, node.material,
@@ -38,19 +31,37 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
parentNode?.material?.preset, parentNode?.material?.preset,
parentNode?.material?.properties, parentNode?.material?.properties,
parentNode?.material?.texture, 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 ( return (
<mesh <mesh
geometry={placeholderGeometry}
material={material} material={material}
position={node.position} position={node.position}
ref={ref} ref={ref}
rotation-y={node.rotation} rotation-y={node.rotation}
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> />
{/* StairSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
) )
} }
@@ -5,14 +5,15 @@ import {
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import { import {
createMaterial, getStairRailingMaterial,
createMaterialFromPresetRef, getStairBodyMaterials,
DEFAULT_STAIR_MATERIAL, type StairBodyMaterials,
} from '../../../lib/materials' } from '../../../systems/stair/stair-materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
type SegmentTransform = { type SegmentTransform = {
@@ -71,6 +72,48 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
node.material?.texture, 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 ( return (
<group <group
position-x={node.position[0]} position-x={node.position[0]}
@@ -81,12 +124,16 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
{...handlers} {...handlers}
> >
{isSegmentBasedStair ? ( {isSegmentBasedStair ? (
<mesh castShadow material={material} name="merged-stair" receiveShadow> <mesh
<boxGeometry args={[0, 0, 0]} /> castShadow
</mesh> geometry={straightPlaceholderGeometry}
material={straightBodyMaterials}
name="merged-stair"
receiveShadow
/>
) : null} ) : null}
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null} {!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
<StairRailings material={material} stair={node} /> <StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? ( {isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
@@ -170,6 +217,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY} geometry={BALUSTER_GEOMETRY}
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`} key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
material={material} material={material}
name="stair-railing-baluster"
position={[point[0], point[1] + railHeight / 2, point[2]]} position={[point[0], point[1] + railHeight / 2, point[2]]}
receiveShadow receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]} scale={[balusterRadius, railHeight, balusterRadius]}
@@ -227,6 +275,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY} geometry={BALUSTER_GEOMETRY}
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`} key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
material={material} material={material}
name="stair-railing-baluster"
position={[point[2], point[1] + railHeight / 2, point[0]]} position={[point[2], point[1] + railHeight / 2, point[0]]}
receiveShadow receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]} 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 BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
const RAIL_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({ function RailSegment({
start, start,
@@ -367,6 +418,7 @@ function RailSegment({
castShadow castShadow
geometry={RAIL_GEOMETRY} geometry={RAIL_GEOMETRY}
material={material} material={material}
name="stair-railing-rail"
position={[midpoint.x, midpoint.y, midpoint.z]} position={[midpoint.x, midpoint.y, midpoint.z]}
quaternion={quaternion} quaternion={quaternion}
receiveShadow 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 stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1) const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
const stepHeight = totalRise / stepCount const stepHeight = totalRise / stepCount
@@ -411,7 +470,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<mesh <mesh
castShadow castShadow
receiveShadow receiveShadow
material={material} material={sideMaterial}
name="stair-side"
position={[0, spiralColumnHeight / 2, 0]} position={[0, spiralColumnHeight / 2, 0]}
> >
<cylinderGeometry <cylinderGeometry
@@ -443,7 +503,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
{isSpiral && (stair.showStepSupports ?? true) ? ( {isSpiral && (stair.showStepSupports ?? true) ? (
<mesh <mesh
castShadow castShadow
material={material} material={sideMaterial}
name="stair-side"
position={[ position={[
Math.cos(midAngle) * Math.cos(midAngle) *
(spiralColumnRadius + (spiralColumnRadius +
@@ -470,7 +531,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh <CurvedStepMesh
endAngle={endAngle} endAngle={endAngle}
innerRadius={innerRadius} innerRadius={innerRadius}
material={material} material={bodyMaterials}
outerRadius={outerRadius} outerRadius={outerRadius}
positionY={0} positionY={0}
startAngle={startAngle} startAngle={startAngle}
@@ -484,7 +545,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh <CurvedStepMesh
endAngle={sweepAngle / 2 + spiralLandingSweep} endAngle={sweepAngle / 2 + spiralLandingSweep}
innerRadius={innerRadius} innerRadius={innerRadius}
material={material} material={bodyMaterials}
outerRadius={outerRadius} outerRadius={outerRadius}
positionY={spiralLastStepTop} positionY={spiralLastStepTop}
startAngle={sweepAngle / 2} startAngle={sweepAngle / 2}
@@ -513,7 +574,7 @@ function CurvedStepMesh({
stepHeight: number stepHeight: number
thickness: number thickness: number
positionY: number positionY: number
material: THREE.Material material: THREE.Material | THREE.Material[]
}) { }) {
const geometry = useMemo( const geometry = useMemo(
() => () =>
@@ -556,15 +617,39 @@ function buildCurvedStepGeometry(
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = []
const triangleMaterialIndices: number[] = []
const pointOnArc = (radius: number, angle: number, y: number) => const pointOnArc = (radius: number, angle: number, y: number) =>
new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius) 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 = ( const pushTriangle = (
a: THREE.Vector3, a: THREE.Vector3,
b: THREE.Vector3, b: THREE.Vector3,
c: THREE.Vector3, c: THREE.Vector3,
normal: THREE.Vector3, normal: THREE.Vector3,
materialIndex: number,
) => { ) => {
const edgeAB = b.clone().sub(a) const edgeAB = b.clone().sub(a)
const edgeAC = c.clone().sub(a) const edgeAC = c.clone().sub(a)
@@ -573,7 +658,9 @@ function buildCurvedStepGeometry(
for (const point of ordered) { for (const point of ordered) {
positions.push(point.x, point.y, point.z) positions.push(point.x, point.y, point.z)
normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z)
pushUv(point, normal, materialIndex)
} }
triangleMaterialIndices.push(materialIndex)
} }
const pushQuad = ( const pushQuad = (
@@ -582,9 +669,10 @@ function buildCurvedStepGeometry(
c: THREE.Vector3, c: THREE.Vector3,
d: THREE.Vector3, d: THREE.Vector3,
normal: THREE.Vector3, normal: THREE.Vector3,
materialIndex: number,
) => { ) => {
pushTriangle(a, b, c, normal) pushTriangle(a, b, c, normal, materialIndex)
pushTriangle(a, c, d, normal) pushTriangle(a, c, d, normal, materialIndex)
} }
const upNormal = new THREE.Vector3(0, 1, 0) 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 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() const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize()
pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal) pushQuad(
pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal) innerStartTop,
pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal) outerStartTop,
pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal) 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) const startInnerBottom = pointOnArc(innerRadius, startAngle, y0)
@@ -634,12 +750,49 @@ function buildCurvedStepGeometry(
sweepDirection * Math.cos(endAngle), sweepDirection * Math.cos(endAngle),
).normalize() ).normalize()
pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal) pushQuad(
pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal) startInnerBottom,
startOuterBottom,
startOuterTop,
startInnerTop,
startNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
endInnerBottom,
endInnerTop,
endOuterTop,
endOuterBottom,
endNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 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() geometry.computeVertexNormals()
return geometry return geometry
} }
@@ -1,12 +1,8 @@
import { useRegistry, useScene, type WallNode } from '@pascal-app/core' import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useRef } from 'react'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
createMaterial,
createMaterialFromPresetRef,
DEFAULT_WALL_MATERIAL,
} from '../../../lib/materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
export const WallRenderer = ({ node }: { node: WallNode }) => { export const WallRenderer = ({ node }: { node: WallNode }) => {
@@ -19,20 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
}, [node.id]) }, [node.id])
const handlers = useNodeEvents(node, 'wall') const handlers = useNodeEvents(node, 'wall')
const material = getVisibleWallMaterials(node)
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_WALL_MATERIAL
return createMaterial(mat)
}, [
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.materialPreset,
])
return ( return (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}> <mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
@@ -38,7 +38,15 @@ export const GroundOccluder = () => {
const polygons: [number, number][][] = [] const polygons: [number, number][][] = []
Object.values(nodes).forEach((node) => { 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 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], position: [e.point.x, e.point.y, e.point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.z], localPosition: [localPoint.x, localPoint.y, localPoint.z],
normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined, normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
faceIndex: e.faceIndex ?? undefined,
object: e.object,
stopPropagation: () => e.stopPropagation(), stopPropagation: () => e.stopPropagation(),
nativeEvent: e, nativeEvent: e,
} as NodeConfig[T]['event'] } 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 { import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
type AnyNodeId,
baseMaterial,
emitter,
getMaterialPresetByRef,
sceneRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { Material } from 'three' import type { Material } from 'three'
import { Color } from 'three' import { type Mesh, Vector3 } from 'three/webgpu'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' import { getMaterialsForWall } from './wall-materials'
const tmpVec = new Vector3() const tmpVec = new Vector3()
const u = new Vector3() const u = new Vector3()
const v = new Vector3() const v = new Vector3()
const DEFAULT_WALL_COLOR = '#f2f0ed'
const WALL_HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
blend: 0.76,
emissiveBlend: 0.92,
emissiveIntensity: 0.46,
},
selection: {
color: new Color('#818cf8'),
blend: 0.32,
emissiveBlend: 0.7,
emissiveIntensity: 0.42,
},
} as const
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
const dotPattern = Fn(() => {
const scale = float(0.1)
const dotSize = float(0.3)
const uv = vec2(positionLocal.x, positionLocal.y).div(scale)
const gridUV = fract(uv)
const dist = length(gridUV.sub(0.5))
const dots = step(dist, dotSize.mul(0.5))
const fadeHeight = float(2.5)
const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y))
return dots.mul(yFade)
})
interface WallMaterials {
visible: Material
invisible: MeshStandardNodeMaterial
deleteVisible: Material
deleteInvisible: MeshStandardNodeMaterial
highlightedVisible: Material
highlightedInvisible: MeshStandardNodeMaterial
materialHash: string
}
const wallMaterialCache = new Map<string, WallMaterials>()
const presetColors = {
white: '#ffffff',
brick: '#8b4513',
concrete: '#808080',
wood: '#deb887',
glass: '#87ceeb',
metal: '#c0c0c0',
plaster: '#f5f5dc',
tile: '#dcdcdc',
marble: '#f5f5f5',
} as const
function getMaterialHash(wallNode: WallNode): string {
if (wallNode.materialPreset) return `preset-ref-${wallNode.materialPreset}`
if (!wallNode.material) return 'none'
const mat = wallNode.material
if (mat.preset && mat.preset !== 'custom') {
return `preset-${mat.preset}`
}
if (mat.properties) {
return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}`
}
return 'default'
}
function getPresetColor(preset: string): string {
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'
}
function getHighlightedColor(color: Color, kind: WallHighlightKind): Color {
const profile = WALL_HIGHLIGHT_PROFILES[kind]
return color.clone().lerp(profile.color, profile.blend)
}
function createHighlightedWallMaterial(
material: Material,
kind: WallHighlightKind,
): Material {
const highlightedMaterial = material.clone() as Material & {
color?: Color
emissive?: Color
emissiveIntensity?: number
needsUpdate?: boolean
}
const profile = WALL_HIGHLIGHT_PROFILES[kind]
if ('color' in highlightedMaterial && highlightedMaterial.color) {
highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind)
}
if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) {
highlightedMaterial.emissive = highlightedMaterial.emissive
.clone()
.lerp(profile.color, profile.emissiveBlend)
}
if ('emissiveIntensity' in highlightedMaterial) {
highlightedMaterial.emissiveIntensity = Math.max(
highlightedMaterial.emissiveIntensity ?? 0,
profile.emissiveIntensity,
)
}
highlightedMaterial.needsUpdate = true
return highlightedMaterial
}
function createBaseVisibleWallMaterial(wallNode: WallNode): Material {
if (wallNode.materialPreset) {
return createMaterialFromPresetRef(wallNode.materialPreset) ?? baseMaterial
}
if (wallNode.material) {
return createMaterial(wallNode.material)
}
return baseMaterial
}
function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id
const materialHash = getMaterialHash(wallNode)
const existing = wallMaterialCache.get(cacheKey)
if (existing && existing.materialHash === materialHash) {
return existing
}
if (existing) {
existing.visible.dispose()
existing.invisible.dispose()
existing.deleteVisible.dispose()
existing.deleteInvisible.dispose()
existing.highlightedVisible.dispose()
existing.highlightedInvisible.dispose()
}
let userColor = DEFAULT_WALL_COLOR
const preset = getMaterialPresetByRef(wallNode.materialPreset)
if (preset?.mapProperties?.color) {
userColor = preset.mapProperties.color
} else if (wallNode.material?.properties?.color) {
userColor = wallNode.material.properties.color
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
userColor = getPresetColor(wallNode.material.preset)
}
const visibleMat = createBaseVisibleWallMaterial(wallNode)
const invisibleMat = new MeshStandardNodeMaterial({
transparent: true,
opacityNode: mix(float(0.0), float(0.24), dotPattern()),
color: userColor,
depthWrite: false,
emissive: userColor,
})
const highlightedVisible = createHighlightedWallMaterial(visibleMat, 'selection')
const highlightedInvisible = createHighlightedWallMaterial(
invisibleMat,
'selection',
) as MeshStandardNodeMaterial
const deleteVisible = createHighlightedWallMaterial(visibleMat, 'delete')
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, 'delete') as MeshStandardNodeMaterial
const result: WallMaterials = {
visible: visibleMat,
invisible: invisibleMat,
deleteVisible,
deleteInvisible,
highlightedVisible,
highlightedInvisible,
materialHash,
}
wallMaterialCache.set(cacheKey, result)
return result
}
function getVisibleWallMaterial(wallNode: WallNode): Material {
return createBaseVisibleWallMaterial(wallNode)
}
function getWallHideState( function getWallHideState(
wallNode: WallNode, wallNode: WallNode,
@@ -301,7 +105,7 @@ export const WallCutout = () => {
? materials.deleteVisible ? materials.deleteVisible
: isSelectionHighlighted : isSelectionHighlighted
? materials.highlightedVisible ? materials.highlightedVisible
: getVisibleWallMaterial(wallNode) : materials.visible
} }
}) })
lastWallMode.current = wallMode lastWallMode.current = wallMode
@@ -311,7 +115,7 @@ export const WallCutout = () => {
}) })
useEffect(() => { useEffect(() => {
const snapshot = new Map<Mesh, Material>() const snapshot = new Map<Mesh, Material | Material[]>()
const restoreForCapture = () => { const restoreForCapture = () => {
sceneRegistry.byType.wall.forEach((wallId) => { sceneRegistry.byType.wall.forEach((wallId) => {
@@ -320,10 +124,10 @@ export const WallCutout = () => {
const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode || wallNode.type !== 'wall') return if (!wallNode || wallNode.type !== 'wall') return
const mats = getMaterialsForWall(wallNode) const mats = getMaterialsForWall(wallNode)
const current = wallMesh.material as Material const current = wallMesh.material as Material | Material[]
snapshot.set(wallMesh, current) snapshot.set(wallMesh, current)
if (current === mats.highlightedVisible || current === mats.deleteVisible) { if (current === mats.highlightedVisible || current === mats.deleteVisible) {
wallMesh.material = getVisibleWallMaterial(wallNode) wallMesh.material = mats.visible
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
wallMesh.material = mats.invisible wallMesh.material = mats.invisible
} }
@@ -0,0 +1,226 @@
import {
baseMaterial,
getEffectiveWallSurfaceMaterial,
getMaterialPresetByRef,
getWallSurfaceMaterialSignature,
resolveMaterial,
type WallNode,
type WallSurfaceMaterialSpec,
} from '@pascal-app/core'
import { Color, type Material } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
const DEFAULT_WALL_COLOR = '#f2f0ed'
const WALL_HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
blend: 0.76,
emissiveBlend: 0.92,
emissiveIntensity: 0.46,
},
selection: {
color: new Color('#818cf8'),
blend: 0.32,
emissiveBlend: 0.7,
emissiveIntensity: 0.42,
},
} as const
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
export type WallMaterialArray = [Material, Material, Material]
export interface WallMaterials {
visible: WallMaterialArray
invisible: WallMaterialArray
deleteVisible: WallMaterialArray
deleteInvisible: WallMaterialArray
highlightedVisible: WallMaterialArray
highlightedInvisible: WallMaterialArray
materialHash: string
}
const wallMaterialCache = new Map<string, WallMaterials>()
const dotPattern = Fn(() => {
const scale = float(0.1)
const dotSize = float(0.3)
const uv = vec2(positionLocal.x, positionLocal.y).div(scale)
const gridUV = fract(uv)
const dist = length(gridUV.sub(0.5))
const dots = step(dist, dotSize.mul(0.5))
const fadeHeight = float(2.5)
const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y))
return dots.mul(yFade)
})
function getSurfaceVisibleMaterial(spec: WallSurfaceMaterialSpec): Material {
if (spec.materialPreset) {
return createMaterialFromPresetRef(spec.materialPreset) ?? baseMaterial
}
if (spec.material) {
return createMaterial(spec.material)
}
return baseMaterial
}
function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string {
const preset = getMaterialPresetByRef(spec.materialPreset)
if (preset?.mapProperties?.color) {
return preset.mapProperties.color
}
if (spec.material) {
return resolveMaterial(spec.material).color
}
return fallback
}
function getHighlightedColor(color: Color, kind: WallHighlightKind): Color {
const profile = WALL_HIGHLIGHT_PROFILES[kind]
return color.clone().lerp(profile.color, profile.blend)
}
function createHighlightedWallMaterial(material: Material, kind: WallHighlightKind): Material {
const highlightedMaterial = material.clone() as Material & {
color?: Color
emissive?: Color
emissiveIntensity?: number
needsUpdate?: boolean
}
const profile = WALL_HIGHLIGHT_PROFILES[kind]
if ('color' in highlightedMaterial && highlightedMaterial.color) {
highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind)
}
if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) {
highlightedMaterial.emissive = highlightedMaterial.emissive
.clone()
.lerp(profile.color, profile.emissiveBlend)
}
if ('emissiveIntensity' in highlightedMaterial) {
highlightedMaterial.emissiveIntensity = Math.max(
highlightedMaterial.emissiveIntensity ?? 0,
profile.emissiveIntensity,
)
}
highlightedMaterial.needsUpdate = true
return highlightedMaterial
}
function createInvisibleWallMaterial(color: string): MeshStandardNodeMaterial {
return new MeshStandardNodeMaterial({
transparent: true,
opacityNode: mix(float(0.0), float(0.24), dotPattern()),
color,
depthWrite: false,
emissive: color,
})
}
function mapWallMaterialArray(
materials: WallMaterialArray,
iteratee: (material: Material, index: number) => Material,
): WallMaterialArray {
return materials.map(iteratee) as WallMaterialArray
}
function disposeOwnedMaterials(materials: WallMaterialArray[]) {
const owned = new Set<Material>()
materials.forEach((entry) => {
entry.forEach((material) => {
owned.add(material)
})
})
owned.forEach((material) => {
material.dispose()
})
}
export function getWallMaterialHash(wallNode: WallNode): string {
return JSON.stringify({
interior: getWallSurfaceMaterialSignature(
getEffectiveWallSurfaceMaterial(wallNode, 'interior'),
),
exterior: getWallSurfaceMaterialSignature(
getEffectiveWallSurfaceMaterial(wallNode, 'exterior'),
),
})
}
export function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id
const materialHash = getWallMaterialHash(wallNode)
const existing = wallMaterialCache.get(cacheKey)
if (existing && existing.materialHash === materialHash) {
return existing
}
if (existing) {
disposeOwnedMaterials([
existing.invisible,
existing.deleteVisible,
existing.deleteInvisible,
existing.highlightedVisible,
existing.highlightedInvisible,
])
}
const interiorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'interior')
const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior')
const visible: WallMaterialArray = [
baseMaterial,
getSurfaceVisibleMaterial(interiorSpec),
getSurfaceVisibleMaterial(exteriorSpec),
]
const invisible: WallMaterialArray = [
createInvisibleWallMaterial(DEFAULT_WALL_COLOR),
createInvisibleWallMaterial(getSurfaceColor(interiorSpec, DEFAULT_WALL_COLOR)),
createInvisibleWallMaterial(getSurfaceColor(exteriorSpec, DEFAULT_WALL_COLOR)),
]
const highlightedVisible = mapWallMaterialArray(visible, (material) =>
createHighlightedWallMaterial(material, 'selection'),
)
const highlightedInvisible = mapWallMaterialArray(invisible, (material) =>
createHighlightedWallMaterial(material, 'selection'),
)
const deleteVisible = mapWallMaterialArray(visible, (material) =>
createHighlightedWallMaterial(material, 'delete'),
)
const deleteInvisible = mapWallMaterialArray(invisible, (material) =>
createHighlightedWallMaterial(material, 'delete'),
)
const result: WallMaterials = {
visible,
invisible,
deleteVisible,
deleteInvisible,
highlightedVisible,
highlightedInvisible,
materialHash,
}
wallMaterialCache.set(cacheKey, result)
return result
}
export function getVisibleWallMaterials(wallNode: WallNode): WallMaterialArray {
return getMaterialsForWall(wallNode).visible
}