feat: Add catalog-based material presets across structural nodes (#231)

* Add catalog-based material presets across structural nodes

* Fix material picker fallback and wall visible highlights

* Preserve wall materials in selection highlights
This commit is contained in:
Sudhir Yadav
2026-04-15 12:16:35 -04:00
committed by GitHub
parent f25806ee8f
commit 57df224948
67 changed files with 1458 additions and 151 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+10
View File
@@ -38,6 +38,16 @@ export {
wallTouchesOthers, wallTouchesOthers,
} from './lib/space-detection' } from './lib/space-detection'
export { baseMaterial, glassMaterial } from './materials' export { baseMaterial, glassMaterial } from './materials'
export {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getMaterialPresetByRef,
getMaterialsForTarget,
LIBRARY_MATERIAL_REF_PREFIX,
MATERIAL_CATALOG,
type MaterialCatalogItem,
toLibraryMaterialRef,
} from './material-library'
export * from './schema' export * from './schema'
export { export {
type ControlValue, type ControlValue,
+627
View File
@@ -0,0 +1,627 @@
import {
type MaterialPresetPayload,
type MaterialTarget,
MaterialTarget as MaterialTargetSchema,
} from './schema/material'
export type MaterialCatalogItem = {
id: string
label: string
description?: string
targets: MaterialTarget[]
previewThumbnailUrl?: string
previewColor?: string
preset: MaterialPresetPayload
}
const WALL_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.wall,
]
const SLAB_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.slab,
]
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.wall,
MaterialTargetSchema.enum.slab,
]
const STAIR_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
]
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
...STAIR_TARGETS,
MaterialTargetSchema.enum.fence,
]
const ROOF_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.roof,
MaterialTargetSchema.enum['roof-segment'],
]
const CEILING_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.ceiling,
]
export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-wood1',
label: 'Wood',
description: 'Warm wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wood1/albedoMap_basecolor.jpg',
normalMap: '/material/wood1/normalMap_normal.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.575,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wood2',
label: 'Wood',
description: 'Textured wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wood2/albedoMap_Wood.jpg',
normalMap: '/material/wood2/normalMap_Wood.jpg',
aoMap: '/material/wood2/aoMap_Wood.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.467,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 2,
normalScaleY: 2,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 2,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wood3',
label: 'Wood',
description: 'Knotted timber finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wood3/albedoMap_knotted-timber.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.489,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 0.2,
normalScaleY: 0.2,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 2,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wood4',
label: 'Wood',
description: 'Oak stretcher finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wood4/albedoMap_oak-stretcher.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.378,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wood5',
label: 'Wood',
description: 'Rich grain wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
preset: {
maps: {
albedoMap: '/material/wood5/albedoMap_3_base_color.webp',
normalMap: '/material/wood5/normalMap_3_normal.jpg',
aoMap: '/material/wood5/aoMap_3_ao.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.6,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 10,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-granite1',
label: 'Granite',
description: 'Polished granite finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/granite1/albedoMap_Granite.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.189,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-marble1',
label: 'Marble',
description: 'Smooth marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/marble1/albedoMap_marble.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.133,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-marble2',
label: 'Marble',
description: 'Soft marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/marble2/albedoMap_marble.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.122,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-parquet1',
label: 'Parquet',
description: 'Parquet wood finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
preset: {
maps: {
albedoMap: '/material/parquet1/albedoMap_parquet.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.644,
metalness: 0.4,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-parquet2',
label: 'Parquet',
description: 'Soft parquet finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/parquet2/albedoMap_parquet.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.6,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wallpaper1',
label: 'Wallpaper',
description: 'Soft wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wallpaper1/albedoMap_1.webp',
normalMap: '/material/wallpaper1/normalMap_NormalMap.webp',
},
mapProperties: {
color: '#ffffff',
roughness: 0.911,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wallpaper2',
label: 'Wallpaper',
description: 'Decorative wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
preset: {
maps: {
albedoMap: '/material/wallpaper2/albedoMap_5.webp',
},
mapProperties: {
color: '#ffffff',
roughness: 0.889,
metalness: 0.255,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wallpaper3',
label: 'Wallpaper',
description: 'Patterned wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wallpaper3/albedoMap_wallpaper3.avif',
},
mapProperties: {
color: '#ffffff',
roughness: 0.887,
metalness: 0.35,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'preset-white',
label: 'White',
description: 'Clean painted finish',
targets: [
...WALL_TARGETS,
...SLAB_TARGETS,
...ROOF_TARGETS,
...STAIR_AND_FENCE_TARGETS,
...CEILING_TARGETS,
],
previewColor: '#ffffff',
preset: {
maps: {},
mapProperties: {
color: '#ffffff',
roughness: 0.9,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: false,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'preset-metal',
label: 'Metal',
description: 'Brushed metal finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#c0c0c0',
preset: {
maps: {},
mapProperties: {
color: '#c7ccd2',
roughness: 0.26,
metalness: 0.82,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: false,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'preset-glass',
label: 'Glass',
description: 'Light glass finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#87ceeb',
preset: {
maps: {},
mapProperties: {
color: '#87ceeb',
roughness: 0.1,
metalness: 0.1,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 2,
opacity: 0.3,
lightMapIntensity: 1,
},
},
},
]
export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] {
return MATERIAL_CATALOG.filter((item) => item.targets.includes(target))
}
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
if (!id) return undefined
return MATERIAL_CATALOG.find((item) => item.id === id)
}
export const LIBRARY_MATERIAL_REF_PREFIX = 'library:'
export function toLibraryMaterialRef(id: string) {
return `${LIBRARY_MATERIAL_REF_PREFIX}${id}`
}
export function getLibraryMaterialIdFromRef(materialRef?: string | null) {
if (!materialRef) return null
if (!materialRef.startsWith(LIBRARY_MATERIAL_REF_PREFIX)) return null
return materialRef.slice(LIBRARY_MATERIAL_REF_PREFIX.length)
}
export function getMaterialPresetByRef(materialRef?: string | null): MaterialPresetPayload | null {
const materialId = getLibraryMaterialIdFromRef(materialRef)
if (!materialId) return null
return getCatalogMaterialById(materialId)?.preset ?? null
}
+12
View File
@@ -7,11 +7,23 @@ export { type Collection, type CollectionId, generateCollectionId } from './coll
// Material // Material
export { export {
DEFAULT_MATERIALS, DEFAULT_MATERIALS,
MaterialMapPropertiesSchema,
MaterialMapsSchema,
MaterialPreset, MaterialPreset,
MaterialPresetPayloadSchema,
MaterialProperties, MaterialProperties,
MaterialSchema, MaterialSchema,
MaterialTarget,
TextureWrapMode,
resolveMaterial, resolveMaterial,
} from './material' } from './material'
export type {
MaterialMapProperties,
MaterialMaps,
MaterialPresetPayload,
MaterialTarget as MaterialTargetValue,
TextureWrapMode as TextureWrapModeValue,
} from './material'
export { BuildingNode } from './nodes/building' export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling' export { CeilingNode } from './nodes/ceiling'
export { DoorNode, DoorSegment } from './nodes/door' export { DoorNode, DoorSegment } from './nodes/door'
+62
View File
@@ -25,6 +25,7 @@ export const MaterialProperties = z.object({
export type MaterialProperties = z.infer<typeof MaterialProperties> export type MaterialProperties = z.infer<typeof MaterialProperties>
export const MaterialSchema = z.object({ export const MaterialSchema = z.object({
id: z.string().optional(),
preset: MaterialPreset.optional(), preset: MaterialPreset.optional(),
properties: MaterialProperties.optional(), properties: MaterialProperties.optional(),
texture: z texture: z
@@ -37,6 +38,67 @@ export const MaterialSchema = z.object({
}) })
export type MaterialSchema = z.infer<typeof MaterialSchema> export type MaterialSchema = z.infer<typeof MaterialSchema>
export const MaterialTarget = z.enum([
'wall',
'roof',
'roof-segment',
'stair',
'stair-segment',
'fence',
'slab',
'ceiling',
'door',
'window',
])
export type MaterialTarget = z.infer<typeof MaterialTarget>
export const TextureWrapMode = z.enum(['Repeat', 'ClampToEdge', 'MirroredRepeat'])
export type TextureWrapMode = z.infer<typeof TextureWrapMode>
export const MaterialMapsSchema = z.object({
albedoMap: z.string().optional(),
metalnessMap: z.string().optional(),
roughnessMap: z.string().optional(),
normalMap: z.string().optional(),
displacementMap: z.string().optional(),
aoMap: z.string().optional(),
emissiveMap: z.string().optional(),
bumpMap: z.string().optional(),
alphaMap: z.string().optional(),
lightMap: z.string().optional(),
})
export type MaterialMaps = z.infer<typeof MaterialMapsSchema>
export const MaterialMapPropertiesSchema = z.object({
color: z.string().default('#ffffff'),
roughness: z.number().min(0).max(1).default(0.5),
metalness: z.number().min(0).max(1).default(0),
repeatX: z.number().default(1),
repeatY: z.number().default(1),
rotation: z.number().default(0),
wrapS: TextureWrapMode.default('Repeat'),
wrapT: TextureWrapMode.default('Repeat'),
normalScaleX: z.number().default(1),
normalScaleY: z.number().default(1),
emissiveIntensity: z.number().default(1),
displacementScale: z.number().default(0.02),
transparent: z.boolean().default(false),
flipY: z.boolean().default(true),
bumpScale: z.number().default(1),
emissiveColor: z.string().default('#000000'),
aoMapIntensity: z.number().default(1),
side: z.number().default(0),
opacity: z.number().min(0).max(1).default(1),
lightMapIntensity: z.number().default(1),
})
export type MaterialMapProperties = z.infer<typeof MaterialMapPropertiesSchema>
export const MaterialPresetPayloadSchema = z.object({
maps: MaterialMapsSchema,
mapProperties: MaterialMapPropertiesSchema,
})
export type MaterialPresetPayload = z.infer<typeof MaterialPresetPayloadSchema>
export const DEFAULT_MATERIALS: Record<MaterialPreset, MaterialProperties> = { export const DEFAULT_MATERIALS: Record<MaterialPreset, MaterialProperties> = {
white: { white: {
color: '#ffffff', color: '#ffffff',
@@ -9,6 +9,7 @@ export const CeilingNode = BaseNode.extend({
type: nodeType('ceiling'), type: nodeType('ceiling'),
children: z.array(ItemNode.shape.id).default([]), children: z.array(ItemNode.shape.id).default([]),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
height: z.number().default(2.5), // Height in meters height: z.number().default(2.5), // Height in meters
+3
View File
@@ -1,6 +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'
export const FenceStyle = z.enum(['slat', 'rail', 'privacy']) export const FenceStyle = z.enum(['slat', 'rail', 'privacy'])
export const FenceBaseStyle = z.enum(['floating', 'grounded']) export const FenceBaseStyle = z.enum(['floating', 'grounded'])
@@ -8,6 +9,8 @@ export const FenceBaseStyle = z.enum(['floating', 'grounded'])
export const FenceNode = BaseNode.extend({ export const FenceNode = BaseNode.extend({
id: objectId('fence'), id: objectId('fence'),
type: nodeType('fence'), type: nodeType('fence'),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
start: z.tuple([z.number(), z.number()]), start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]),
height: z.number().default(1.8), height: z.number().default(1.8),
@@ -11,6 +11,7 @@ export const RoofSegmentNode = BaseNode.extend({
id: objectId('rseg'), id: objectId('rseg'),
type: nodeType('roof-segment'), type: nodeType('roof-segment'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: 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),
+1
View File
@@ -8,6 +8,7 @@ export const RoofNode = BaseNode.extend({
id: objectId('roof'), id: objectId('roof'),
type: nodeType('roof'), type: nodeType('roof'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: 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),
+1
View File
@@ -7,6 +7,7 @@ export const SlabNode = BaseNode.extend({
id: objectId('slab'), id: objectId('slab'),
type: nodeType('slab'), type: nodeType('slab'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
elevation: z.number().default(0.05), // Elevation in meters elevation: z.number().default(0.05), // Elevation in meters
@@ -15,6 +15,7 @@ export const StairSegmentNode = BaseNode.extend({
id: objectId('sseg'), id: objectId('sseg'),
type: nodeType('stair-segment'), type: nodeType('stair-segment'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: 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),
+1
View File
@@ -16,6 +16,7 @@ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
type: nodeType('stair'), type: nodeType('stair'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: 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),
+1
View File
@@ -12,6 +12,7 @@ export const WallNode = BaseNode.extend({
type: nodeType('wall'), type: nodeType('wall'),
children: z.array(ItemNode.shape.id).default([]), children: z.array(ItemNode.shape.id).default([]),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
thickness: z.number().optional(), thickness: z.number().optional(),
height: z.number().optional(), height: z.number().optional(),
// e.g., start/end points for path // e.g., start/end points for path
@@ -10,6 +10,66 @@ type FencePart = {
scale: [number, number, number] scale: [number, number, number]
} }
function applyFenceUVs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
if (!(position && normal)) return
const uvs = new Float32Array(position.count * 2)
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index)
const py = position.getY(index)
const pz = position.getZ(index)
minX = Math.min(minX, px)
minY = Math.min(minY, py)
minZ = Math.min(minZ, pz)
maxX = Math.max(maxX, px)
maxY = Math.max(maxY, py)
maxZ = Math.max(maxZ, pz)
}
const width = Math.max(maxX - minX, 0.001)
const height = Math.max(maxY - minY, 0.001)
const depth = Math.max(maxZ - minZ, 0.001)
for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index)
const py = position.getY(index)
const pz = position.getZ(index)
const nx = Math.abs(normal.getX(index))
const ny = Math.abs(normal.getY(index))
const nz = Math.abs(normal.getZ(index))
let u = 0
let v = 0
if (ny >= nx && ny >= nz) {
u = (px - minX) / width
v = (pz - minZ) / depth
} else if (nx >= nz) {
u = (pz - minZ) / depth
v = (py - minY) / height
} else {
u = (px - minX) / width
v = (py - minY) / height
}
uvs[index * 2] = u
uvs[index * 2 + 1] = v
}
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
}
function getStyleDefaults(style: FenceNode['style']) { function getStyleDefaults(style: FenceNode['style']) {
if (style === 'privacy') { if (style === 'privacy') {
return { spacingFactor: 0.42, postFactor: 1.35, baseFactor: 1.2, topFactor: 1.2 } return { spacingFactor: 0.42, postFactor: 1.35, baseFactor: 1.2, topFactor: 1.2 }
@@ -103,6 +163,7 @@ function generateFenceGeometry(fence: FenceNode) {
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)
merged.computeVertexNormals() merged.computeVertexNormals()
return merged return merged
} }
@@ -18,6 +18,13 @@ import {
// Reusable CSG evaluator for better performance // Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
// ============================================================================ // ============================================================================
// WALL SYSTEM // WALL SYSTEM
// ============================================================================ // ============================================================================
@@ -205,6 +212,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()
ensureUv2Attribute(geometry)
// Apply CSG subtraction for cutouts (doors/windows) // Apply CSG subtraction for cutouts (doors/windows)
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)
@@ -239,6 +247,7 @@ export function generateExtrudedWall(
const resultGeometry = resultBrush.geometry const resultGeometry = resultBrush.geometry
resultGeometry.computeVertexNormals() resultGeometry.computeVertexNormals()
ensureUv2Attribute(resultGeometry)
return resultGeometry return resultGeometry
} }
@@ -1,73 +1,68 @@
'use client' 'use client'
import { DEFAULT_MATERIALS, type MaterialPreset, type MaterialSchema } from '@pascal-app/core' import {
getMaterialsForTarget,
toLibraryMaterialRef,
type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core'
import { useState } from 'react' import { useState } from 'react'
const PRESET_COLORS: Record<MaterialPreset, string> = {
white: '#ffffff',
brick: '#8b4513',
concrete: '#808080',
wood: '#deb887',
glass: '#87ceeb',
metal: '#c0c0c0',
plaster: '#f5f5dc',
tile: '#d3d3d3',
marble: '#fafafa',
custom: '#ffffff',
}
const PRESET_LABELS: Record<MaterialPreset, string> = {
white: 'White',
brick: 'Brick',
concrete: 'Concrete',
wood: 'Wood',
glass: 'Glass',
metal: 'Metal',
plaster: 'Plaster',
tile: 'Tile',
marble: 'Marble',
custom: 'Custom',
}
type MaterialPickerProps = { type MaterialPickerProps = {
nodeType?: MaterialTarget
value?: MaterialSchema value?: MaterialSchema
onChange: (material: MaterialSchema) => void selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
} }
export function MaterialPicker({ value, onChange }: MaterialPickerProps) { export function MaterialPicker({
const [showCustom, setShowCustom] = useState<boolean>( nodeType,
value?.preset === 'custom' || !!value?.properties, value,
) selectedMaterialPreset,
onChange,
onSelectMaterialPreset,
}: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
const currentPreset = value?.preset || 'white' const currentProps = value?.properties || {
const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset] color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
}
const selectedCatalogId =
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handlePresetChange = (preset: MaterialPreset) => { const handleCatalogSelect = (materialId: string) => {
if (preset === 'custom') { setShowCustom(false)
setShowCustom(true) onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
onChange({ }
preset: 'custom',
properties: { const handleCustomOpen = () => {
color: value?.properties?.color || '#ffffff', setShowCustom(true)
roughness: value?.properties?.roughness ?? 0.5, onChange?.({
metalness: value?.properties?.metalness ?? 0, preset: 'custom',
opacity: value?.properties?.opacity ?? 1, properties: {
transparent: value?.properties?.transparent ?? false, color: value?.properties?.color || '#ffffff',
side: value?.properties?.side ?? 'front', roughness: value?.properties?.roughness ?? 0.5,
}, metalness: value?.properties?.metalness ?? 0,
}) opacity: value?.properties?.opacity ?? 1,
} else { transparent: value?.properties?.transparent ?? false,
setShowCustom(false) side: value?.properties?.side ?? 'front',
onChange({ preset }) },
} })
} }
const handlePropertyChange = ( const handlePropertyChange = (
prop: keyof typeof currentProps, prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps], val: (typeof currentProps)[keyof typeof currentProps],
) => { ) => {
onChange({ onChange?.({
preset: showCustom ? 'custom' : currentPreset, preset: 'custom',
properties: { properties: {
...currentProps, ...currentProps,
[prop]: val, [prop]: val,
@@ -77,31 +72,56 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-5 gap-1.5"> {(catalogItems.length > 0 || onChange) && (
{(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => ( <div className="space-y-2">
<button {catalogItems.length > 0 ? (
className={`h-8 w-8 rounded border-2 transition-all ${ <div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
currentPreset === preset ) : null}
? 'border-blue-500 ring-2 ring-blue-500/30' <div className="flex flex-wrap gap-1.5">
: 'border-gray-300 hover:border-gray-400' {catalogItems.map((item) => (
}`} <button
key={preset} className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
onClick={() => handlePresetChange(preset)} selectedCatalogId === toLibraryMaterialRef(item.id)
style={{ ? 'border-blue-500 ring-2 ring-blue-500/30'
backgroundColor: PRESET_COLORS[preset], : 'border-gray-300 hover:border-gray-400'
backgroundImage: }`}
preset === 'glass' key={item.id}
? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)' onClick={() => handleCatalogSelect(item.id)}
: undefined, title={item.label}
backgroundSize: preset === 'glass' ? '8px 8px' : undefined, type="button"
}} >
title={PRESET_LABELS[preset]} {item.previewThumbnailUrl ? (
type="button" <img
/> alt={item.label}
))} className="h-full w-full object-cover"
</div> src={item.previewThumbnailUrl}
/>
) : item.previewColor ? (
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
) : (
<div className="h-full w-full bg-gray-100" />
)}
</button>
))}
{onChange ? (
<button
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
showCustom
? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
: 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
}`}
onClick={handleCustomOpen}
title="Custom"
type="button"
>
Custom
</button>
) : null}
</div>
</div>
)}
{showCustom && ( {showCustom && onChange && (
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Color</label> <label className="w-16 text-gray-500 text-xs">Color</label>
@@ -34,7 +34,14 @@ export function CeilingPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -223,7 +230,13 @@ export function CeilingPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="ceiling"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -1,8 +1,17 @@
'use client' 'use client'
import { type AnyNode, type AnyNodeId, type FenceBaseStyle, type FenceNode, type FenceStyle, useScene } from '@pascal-app/core' import {
type AnyNode,
type AnyNodeId,
type FenceBaseStyle,
type FenceNode,
type FenceStyle,
type MaterialSchema,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react' import { useCallback } from 'react'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -62,6 +71,20 @@ export function FencePanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null
const dx = node.end[0] - node.start[0] const dx = node.end[0] - node.start[0]
@@ -179,6 +202,16 @@ export function FencePanel() {
value={node.edgeInset} value={node.edgeInset}
/> />
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="fence"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -43,7 +43,14 @@ export function RoofPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -255,7 +262,13 @@ export function RoofPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="roof"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -57,7 +57,14 @@ export function RoofSegmentPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -319,7 +326,13 @@ export function RoofSegmentPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="roof-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -30,9 +30,16 @@ export function SlabPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback( const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -221,7 +228,13 @@ export function SlabPanel() {
</div> </div>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="slab"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -70,7 +70,14 @@ export function StairPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -470,7 +477,13 @@ export function StairPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="stair"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -65,7 +65,14 @@ export function StairSegmentPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -332,7 +339,13 @@ export function StairSegmentPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="stair-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -55,9 +55,16 @@ export function WallPanel() {
[node, handleUpdate], [node, handleUpdate],
) )
const handleMaterialChange = useCallback( const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -116,7 +123,13 @@ export function WallPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -1,4 +1,4 @@
import { type CeilingNode, resolveMaterial, useRegistry } from '@pascal-app/core' import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
@@ -39,10 +39,11 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const handlers = useNodeEvents(node, 'ceiling') const handlers = useNodeEvents(node, 'ceiling')
const materials = useMemo(() => { const materials = useMemo(() => {
const props = resolveMaterial(node.material) const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material)
const color = props.color || '#999999' const color = props.color || '#999999'
return createCeilingMaterials(color) return createCeilingMaterials(color)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return ( return (
<mesh material={materials.bottomMaterial} ref={ref}> <mesh material={materials.bottomMaterial} ref={ref}>
@@ -2,12 +2,28 @@ import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, 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 { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
export const FenceRenderer = ({ node }: { node: FenceNode }) => { export const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
const handlers = useNodeEvents(node, 'fence') const handlers = useNodeEvents(node, 'fence')
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, []) const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
useRegistry(node.id, 'fence', ref) useRegistry(node.id, 'fence', ref)
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -1,24 +1,43 @@
import { type RoofSegmentNode, useRegistry } from '@pascal-app/core' import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import type * as THREE from 'three' import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial } from '../../../lib/materials' import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
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 }) => {
const ref = useRef<THREE.Mesh>(null!) const ref = useRef<THREE.Mesh>(null!)
const nodes = useScene((state) => state.nodes)
useRegistry(node.id, 'roof-segment', ref) useRegistry(node.id, 'roof-segment', ref)
const handlers = useNodeEvents(node, 'roof-segment') const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const parentNode =
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
const customMaterial = useMemo(() => { const customMaterial = useMemo(() => {
const mat = node.material const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
const effectiveMaterial = node.material ?? parentNode?.material
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return null if (!mat) return null
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
@@ -2,7 +2,7 @@ import { type RoofNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import type * as THREE from 'three' import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial } from '../../../lib/materials' import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
import { roofDebugMaterials, roofMaterials } from './roof-materials' import { roofDebugMaterials, roofMaterials } from './roof-materials'
@@ -16,10 +16,12 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const customMaterial = useMemo(() => { const customMaterial = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material const mat = node.material
if (!mat) return null if (!mat) return null
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
@@ -2,7 +2,11 @@ import { type SlabNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useMemo, 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 { createMaterial, DEFAULT_SLAB_MATERIAL } from '../../../lib/materials' import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_SLAB_MATERIAL,
} from '../../../lib/materials'
export const SlabRenderer = ({ node }: { node: SlabNode }) => { export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
@@ -12,10 +16,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)
if (presetMaterial) return presetMaterial
const mat = node.material const mat = node.material
if (!mat) return DEFAULT_SLAB_MATERIAL if (!mat) return DEFAULT_SLAB_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.materialPreset,
])
return ( return (
<mesh <mesh
@@ -1,11 +1,12 @@
import { 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 { useLayoutEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/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!)
const nodes = useScene((state) => state.nodes)
useRegistry(node.id, 'stair-segment', ref) useRegistry(node.id, 'stair-segment', ref)
@@ -14,12 +15,30 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
}, [node.id]) }, [node.id])
const handlers = useNodeEvents(node, 'stair-segment') const handlers = useNodeEvents(node, 'stair-segment')
const parentNode =
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
const material = useMemo(() => { const material = useMemo(() => {
const mat = node.material const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
const effectiveMaterial = node.material ?? parentNode?.material
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return DEFAULT_STAIR_MATERIAL if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
])
return ( return (
<mesh <mesh
@@ -2,7 +2,7 @@ import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, use
import { useLayoutEffect, useMemo, useRef } from 'react' import { 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, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
type SegmentTransform = { type SegmentTransform = {
@@ -47,10 +47,12 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
const handlers = useNodeEvents(node, 'stair') const handlers = useNodeEvents(node, 'stair')
const material = useMemo(() => { const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return ( return (
<group <group
@@ -2,7 +2,11 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, 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 { createMaterial, DEFAULT_WALL_MATERIAL } from '../../../lib/materials' import {
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 }) => {
@@ -17,10 +21,18 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
const handlers = useNodeEvents(node, 'wall') const handlers = useNodeEvents(node, 'wall')
const material = useMemo(() => { const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material const mat = node.material
if (!mat) return DEFAULT_WALL_MATERIAL if (!mat) return DEFAULT_WALL_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
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}>
@@ -310,6 +310,10 @@ const PostProcessingPasses = () => {
bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4) bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4)
bgUniform.current.value.copy(bgCurrent.current) bgUniform.current.value.copy(bgCurrent.current)
if (!isInitialized) {
return
}
if (hasPipelineErrorRef.current || !renderPipelineRef.current) { if (hasPipelineErrorRef.current || !renderPipelineRef.current) {
try { try {
if ((renderer as any).setClearAlpha) { if ((renderer as any).setClearAlpha) {
@@ -281,6 +281,10 @@ export const SelectionManager = () => {
if (!strategy) return if (!strategy) return
if (strategy.isValid(event.node)) { if (strategy.isValid(event.node)) {
event.stopPropagation() event.stopPropagation()
if (event.node.type === 'slab') {
useViewer.setState({ hoveredId: null })
return
}
useViewer.setState({ hoveredId: event.node.id }) useViewer.setState({ hoveredId: event.node.id })
} }
} }
@@ -388,11 +392,14 @@ const OutlinerSync = () => {
const selection = useViewer((s) => s.selection) const selection = useViewer((s) => s.selection)
const hoveredId = useViewer((s) => s.hoveredId) const hoveredId = useViewer((s) => s.hoveredId)
const outliner = useViewer((s) => s.outliner) const outliner = useViewer((s) => s.outliner)
const nodes = useScene((s) => s.nodes)
useEffect(() => { useEffect(() => {
// Sync selected objects // Sync selected objects
outliner.selectedObjects.length = 0 outliner.selectedObjects.length = 0
for (const id of selection.selectedIds) { for (const id of selection.selectedIds) {
const node = nodes[id as AnyNodeId]
if (node?.type === 'slab') continue
const obj = sceneRegistry.nodes.get(id) const obj = sceneRegistry.nodes.get(id)
if (obj) outliner.selectedObjects.push(obj) if (obj) outliner.selectedObjects.push(obj)
} }
@@ -400,10 +407,12 @@ const OutlinerSync = () => {
// Sync hovered objects // Sync hovered objects
outliner.hoveredObjects.length = 0 outliner.hoveredObjects.length = 0
if (hoveredId) { if (hoveredId) {
const hoveredNode = nodes[hoveredId as AnyNodeId]
if (hoveredNode?.type === 'slab') return
const obj = sceneRegistry.nodes.get(hoveredId) const obj = sceneRegistry.nodes.get(hoveredId)
if (obj) outliner.hoveredObjects.push(obj) if (obj) outliner.hoveredObjects.push(obj)
} }
}, [selection, hoveredId, outliner]) }, [selection, hoveredId, outliner, nodes])
return null return null
} }
+246 -4
View File
@@ -1,4 +1,11 @@
import { type MaterialProperties, type MaterialSchema, resolveMaterial } from '@pascal-app/core' import {
type MaterialMapProperties,
type MaterialPresetPayload,
type MaterialProperties,
type MaterialSchema,
getMaterialPresetByRef,
resolveMaterial,
} from '@pascal-app/core'
import * as THREE from 'three' import * as THREE from 'three'
const sideMap: Record<MaterialProperties['side'], THREE.Side> = { const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
@@ -8,19 +15,247 @@ const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
} }
const materialCache = new Map<string, THREE.MeshStandardMaterial>() const materialCache = new Map<string, THREE.MeshStandardMaterial>()
const textureCache = new Map<string, THREE.Texture>()
const textureLoadPromises = new Map<string, Promise<THREE.Texture | null>>()
const textureLoader = new THREE.TextureLoader()
const wrapMap = {
Repeat: THREE.RepeatWrapping,
ClampToEdge: THREE.ClampToEdgeWrapping,
MirroredRepeat: THREE.MirroredRepeatWrapping,
} as const
type StandardMaterial = THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial
type TextureSlot =
| 'map'
| 'normalMap'
| 'roughnessMap'
| 'metalnessMap'
| 'displacementMap'
| 'aoMap'
| 'bumpMap'
| 'alphaMap'
| 'lightMap'
| 'emissiveMap'
const SRGB_TEXTURE_SLOTS: TextureSlot[] = ['map', 'emissiveMap']
function getCacheKey(props: MaterialProperties): string { function getCacheKey(props: MaterialProperties): string {
return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}` return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}`
} }
export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial { function getTextureKey(material?: MaterialSchema): string {
const props = resolveMaterial(material) const texture = material?.texture
const cacheKey = getCacheKey(props) if (!texture) return 'none'
const repeat = texture.repeat?.join('x') ?? 'default'
const scale = texture.scale ?? 'default'
return `${texture.url}-${repeat}-${scale}`
}
function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
const textureConfig = material?.texture
if (!textureConfig?.url) return undefined
const cacheKey = getTextureKey(material)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(textureConfig.url)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
const repeatX = textureConfig.repeat?.[0] ?? textureConfig.scale ?? 1
const repeatY = textureConfig.repeat?.[1] ?? textureConfig.scale ?? 1
texture.repeat.set(repeatX, repeatY)
texture.colorSpace = THREE.SRGBColorSpace
textureCache.set(cacheKey, texture)
return texture
}
function isStandardMaterial(material: THREE.Material): material is StandardMaterial {
return (
material instanceof THREE.MeshStandardMaterial ||
material instanceof THREE.MeshPhysicalMaterial
)
}
function applyTextureProperties(
texture: THREE.Texture,
props: MaterialMapProperties,
slot?: TextureSlot,
): THREE.Texture {
texture.wrapS = wrapMap[props.wrapS]
texture.wrapT = wrapMap[props.wrapT]
texture.repeat.set(props.repeatX, props.repeatY)
texture.rotation = props.rotation
texture.flipY = props.flipY
texture.colorSpace = SRGB_TEXTURE_SLOTS.includes(slot ?? 'map')
? THREE.SRGBColorSpace
: THREE.NoColorSpace
texture.needsUpdate = true
return texture
}
function getPresetTextureCacheKey(path: string, props: MaterialMapProperties, slot?: TextureSlot): string {
return `${path}-${props.repeatX}-${props.repeatY}-${props.rotation}-${props.wrapS}-${props.wrapT}-${props.flipY}-${slot ?? 'map'}`
}
function getPresetTexture(path: string, props: MaterialMapProperties, slot?: TextureSlot): THREE.Texture {
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(path)
applyTextureProperties(texture, props, slot)
textureCache.set(cacheKey, texture)
return texture
}
async function loadPresetTexture(
path: string,
props: MaterialMapProperties,
slot?: TextureSlot,
): Promise<THREE.Texture | null> {
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const existingPromise = textureLoadPromises.get(cacheKey)
if (existingPromise) return existingPromise
const promise = textureLoader
.loadAsync(path)
.then((texture) => {
applyTextureProperties(texture, props, slot)
textureCache.set(cacheKey, texture)
textureLoadPromises.delete(cacheKey)
return texture
})
.catch((error) => {
console.warn('[viewer] Failed to load material texture', path, error)
textureLoadPromises.delete(cacheKey)
return null
})
textureLoadPromises.set(cacheKey, promise)
return promise
}
function queueTextureAssignment(
material: StandardMaterial,
slot: TextureSlot,
path: string | undefined,
props: MaterialMapProperties,
) {
if (!path) {
material[slot] = null
return
}
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) {
material[slot] = cached
return
}
material[slot] = null
void loadPresetTexture(path, props, slot).then((texture) => {
if (!texture) return
material[slot] = texture
material.needsUpdate = true
})
}
function applyMaterialMapProperties(material: StandardMaterial, mapProperties: MaterialMapProperties) {
material.color.set(mapProperties.color)
material.roughness = mapProperties.roughness
material.metalness = mapProperties.metalness
material.emissiveIntensity = mapProperties.emissiveIntensity
material.emissive.set(mapProperties.emissiveColor)
material.displacementScale = mapProperties.displacementScale
material.bumpScale = mapProperties.bumpScale
material.aoMapIntensity = mapProperties.aoMapIntensity
material.lightMapIntensity = mapProperties.lightMapIntensity
material.transparent = mapProperties.transparent
material.opacity = mapProperties.opacity
material.side =
mapProperties.side === 0
? THREE.FrontSide
: mapProperties.side === 1
? THREE.BackSide
: THREE.DoubleSide
material.normalScale.set(mapProperties.normalScaleX, mapProperties.normalScaleY)
material.needsUpdate = true
}
function applyMaterialPresetTextures(
material: StandardMaterial,
preset: MaterialPresetPayload,
) {
const { maps, mapProperties } = preset
queueTextureAssignment(material, 'map', maps.albedoMap, mapProperties)
queueTextureAssignment(material, 'normalMap', maps.normalMap, mapProperties)
queueTextureAssignment(material, 'roughnessMap', maps.roughnessMap, mapProperties)
queueTextureAssignment(material, 'metalnessMap', maps.metalnessMap, mapProperties)
queueTextureAssignment(material, 'displacementMap', maps.displacementMap, mapProperties)
queueTextureAssignment(material, 'aoMap', maps.aoMap, mapProperties)
queueTextureAssignment(material, 'bumpMap', maps.bumpMap, mapProperties)
queueTextureAssignment(material, 'alphaMap', maps.alphaMap, mapProperties)
queueTextureAssignment(material, 'lightMap', maps.lightMap, mapProperties)
queueTextureAssignment(material, 'emissiveMap', maps.emissiveMap, mapProperties)
material.needsUpdate = true
}
export function applyMaterialPresetToMaterials(
materialInput: THREE.Material | THREE.Material[],
preset: MaterialPresetPayload | null | undefined,
) {
if (!preset) return
const materials = (Array.isArray(materialInput) ? materialInput : [materialInput]).filter(
isStandardMaterial,
)
if (materials.length === 0) return
for (const material of materials) {
applyMaterialMapProperties(material, preset.mapProperties)
applyMaterialPresetTextures(material, preset)
}
}
export function createMaterialFromPreset(preset: MaterialPresetPayload): THREE.MeshStandardMaterial {
const cacheKey = JSON.stringify(preset)
if (materialCache.has(cacheKey)) { if (materialCache.has(cacheKey)) {
return materialCache.get(cacheKey)! return materialCache.get(cacheKey)!
} }
const material = new THREE.MeshStandardMaterial()
applyMaterialPresetToMaterials(material, preset)
materialCache.set(cacheKey, material)
return material
}
export function createMaterialFromPresetRef(materialPreset?: string): THREE.MeshStandardMaterial | null {
const preset = getMaterialPresetByRef(materialPreset)
if (!preset) return null
return createMaterialFromPreset(preset)
}
export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial {
const props = resolveMaterial(material)
const cacheKey = `${getCacheKey(props)}-${getTextureKey(material)}`
if (materialCache.has(cacheKey)) {
return materialCache.get(cacheKey)!
}
const map = getTexture(material)
const threeMaterial = new THREE.MeshStandardMaterial({ const threeMaterial = new THREE.MeshStandardMaterial({
color: props.color, color: props.color,
roughness: props.roughness, roughness: props.roughness,
@@ -28,6 +263,7 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
opacity: props.opacity, opacity: props.opacity,
transparent: props.transparent, transparent: props.transparent,
side: sideMap[props.side], side: sideMap[props.side],
map,
}) })
materialCache.set(cacheKey, threeMaterial) materialCache.set(cacheKey, threeMaterial)
@@ -70,4 +306,10 @@ export function clearMaterialCache(): void {
material.dispose() material.dispose()
} }
materialCache.clear() materialCache.clear()
for (const texture of textureCache.values()) {
texture.dispose()
}
textureCache.clear()
textureLoadPromises.clear()
} }
@@ -2,6 +2,7 @@ import {
type AnyNodeId, type AnyNodeId,
baseMaterial, baseMaterial,
emitter, emitter,
getMaterialPresetByRef,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallNode, type WallNode,
@@ -13,6 +14,7 @@ import { Color } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' 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'
const tmpVec = new Vector3() const tmpVec = new Vector3()
const u = new Vector3() const u = new Vector3()
@@ -21,12 +23,14 @@ const DEFAULT_WALL_COLOR = '#f2f0ed'
const WALL_HIGHLIGHT_PROFILES = { const WALL_HIGHLIGHT_PROFILES = {
delete: { delete: {
color: new Color('#dc2626'), color: new Color('#dc2626'),
blend: 0.78, blend: 0.76,
emissiveBlend: 0.92,
emissiveIntensity: 0.46, emissiveIntensity: 0.46,
}, },
selection: { selection: {
color: new Color('#818cf8'), color: new Color('#818cf8'),
blend: 0.32, blend: 0.32,
emissiveBlend: 0.7,
emissiveIntensity: 0.42, emissiveIntensity: 0.42,
}, },
} as const } as const
@@ -51,11 +55,11 @@ const dotPattern = Fn(() => {
}) })
interface WallMaterials { interface WallMaterials {
visible: MeshStandardNodeMaterial visible: Material
invisible: MeshStandardNodeMaterial invisible: MeshStandardNodeMaterial
deleteVisible: MeshStandardNodeMaterial deleteVisible: Material
deleteInvisible: MeshStandardNodeMaterial deleteInvisible: MeshStandardNodeMaterial
highlightedVisible: MeshStandardNodeMaterial highlightedVisible: Material
highlightedInvisible: MeshStandardNodeMaterial highlightedInvisible: MeshStandardNodeMaterial
materialHash: string materialHash: string
} }
@@ -75,6 +79,7 @@ const presetColors = {
} as const } as const
function getMaterialHash(wallNode: WallNode): string { function getMaterialHash(wallNode: WallNode): string {
if (wallNode.materialPreset) return `preset-ref-${wallNode.materialPreset}`
if (!wallNode.material) return 'none' if (!wallNode.material) return 'none'
const mat = wallNode.material const mat = wallNode.material
if (mat.preset && mat.preset !== 'custom') { if (mat.preset && mat.preset !== 'custom') {
@@ -90,27 +95,54 @@ function getPresetColor(preset: string): string {
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff' return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'
} }
function getHighlightedColor(color: string, kind: WallHighlightKind): Color { function getHighlightedColor(color: Color, kind: WallHighlightKind): Color {
const profile = WALL_HIGHLIGHT_PROFILES[kind] const profile = WALL_HIGHLIGHT_PROFILES[kind]
return new Color(color).lerp(profile.color, profile.blend) return color.clone().lerp(profile.color, profile.blend)
} }
function createHighlightedWallMaterial( function createHighlightedWallMaterial(
material: MeshStandardNodeMaterial, material: Material,
baseColor: string,
kind: WallHighlightKind, kind: WallHighlightKind,
): MeshStandardNodeMaterial { ): Material {
const highlightedMaterial = material.clone() const highlightedMaterial = material.clone() as Material & {
const highlightedColor = getHighlightedColor(baseColor, kind) color?: Color
emissive?: Color
emissiveIntensity?: number
needsUpdate?: boolean
}
const profile = WALL_HIGHLIGHT_PROFILES[kind] const profile = WALL_HIGHLIGHT_PROFILES[kind]
highlightedMaterial.color = highlightedColor if ('color' in highlightedMaterial && highlightedMaterial.color) {
highlightedMaterial.emissive = highlightedColor.clone() highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind)
highlightedMaterial.emissiveIntensity = profile.emissiveIntensity }
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 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 { function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id const cacheKey = wallNode.id
const materialHash = getMaterialHash(wallNode) const materialHash = getMaterialHash(wallNode)
@@ -130,19 +162,16 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
} }
let userColor = DEFAULT_WALL_COLOR let userColor = DEFAULT_WALL_COLOR
if (wallNode.material?.properties?.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 userColor = wallNode.material.properties.color
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') { } else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
userColor = getPresetColor(wallNode.material.preset) userColor = getPresetColor(wallNode.material.preset)
} }
const visibleMat = wallNode.material const visibleMat = createBaseVisibleWallMaterial(wallNode)
? new MeshStandardNodeMaterial({
color: userColor,
roughness: 1,
metalness: 0,
})
: (baseMaterial.clone() as MeshStandardNodeMaterial)
const invisibleMat = new MeshStandardNodeMaterial({ const invisibleMat = new MeshStandardNodeMaterial({
transparent: true, transparent: true,
@@ -152,10 +181,13 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
emissive: userColor, emissive: userColor,
}) })
const highlightedVisible = createHighlightedWallMaterial(visibleMat, userColor, 'selection') const highlightedVisible = createHighlightedWallMaterial(visibleMat, 'selection')
const highlightedInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'selection') const highlightedInvisible = createHighlightedWallMaterial(
const deleteVisible = createHighlightedWallMaterial(visibleMat, userColor, 'delete') invisibleMat,
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'delete') 'selection',
) as MeshStandardNodeMaterial
const deleteVisible = createHighlightedWallMaterial(visibleMat, 'delete')
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, 'delete') as MeshStandardNodeMaterial
const result: WallMaterials = { const result: WallMaterials = {
visible: visibleMat, visible: visibleMat,
@@ -170,6 +202,10 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
return result return result
} }
function getVisibleWallMaterial(wallNode: WallNode): Material {
return createBaseVisibleWallMaterial(wallNode)
}
function getWallHideState( function getWallHideState(
wallNode: WallNode, wallNode: WallNode,
wallMesh: Mesh, wallMesh: Mesh,
@@ -265,9 +301,7 @@ export const WallCutout = () => {
? materials.deleteVisible ? materials.deleteVisible
: isSelectionHighlighted : isSelectionHighlighted
? materials.highlightedVisible ? materials.highlightedVisible
: wallNode.material : getVisibleWallMaterial(wallNode)
? materials.visible
: baseMaterial
} }
}) })
lastWallMode.current = wallMode lastWallMode.current = wallMode
@@ -289,7 +323,7 @@ export const WallCutout = () => {
const current = wallMesh.material as Material const current = wallMesh.material as 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 = mats.visible wallMesh.material = getVisibleWallMaterial(wallNode)
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
wallMesh.material = mats.invisible wallMesh.material = mats.invisible
} }