diff --git a/apps/editor/public/material/granite1/albedoMap_Granite.jpg b/apps/editor/public/material/granite1/albedoMap_Granite.jpg new file mode 100644 index 00000000..eb8dcc4f Binary files /dev/null and b/apps/editor/public/material/granite1/albedoMap_Granite.jpg differ diff --git a/apps/editor/public/material/granite1/granite_thumbnail.webp b/apps/editor/public/material/granite1/granite_thumbnail.webp new file mode 100644 index 00000000..850ba54d Binary files /dev/null and b/apps/editor/public/material/granite1/granite_thumbnail.webp differ diff --git a/apps/editor/public/material/marble1/albedoMap_marble.jpg b/apps/editor/public/material/marble1/albedoMap_marble.jpg new file mode 100644 index 00000000..3e2702e1 Binary files /dev/null and b/apps/editor/public/material/marble1/albedoMap_marble.jpg differ diff --git a/apps/editor/public/material/marble1/marble1_thumbnail.webp b/apps/editor/public/material/marble1/marble1_thumbnail.webp new file mode 100644 index 00000000..e90e9294 Binary files /dev/null and b/apps/editor/public/material/marble1/marble1_thumbnail.webp differ diff --git a/apps/editor/public/material/marble2/albedoMap_marble.jpg b/apps/editor/public/material/marble2/albedoMap_marble.jpg new file mode 100644 index 00000000..c30fac62 Binary files /dev/null and b/apps/editor/public/material/marble2/albedoMap_marble.jpg differ diff --git a/apps/editor/public/material/marble2/marble2_thumbnail.webp b/apps/editor/public/material/marble2/marble2_thumbnail.webp new file mode 100644 index 00000000..3a60c6cb Binary files /dev/null and b/apps/editor/public/material/marble2/marble2_thumbnail.webp differ diff --git a/apps/editor/public/material/parquet1/albedoMap_parquet.jpg b/apps/editor/public/material/parquet1/albedoMap_parquet.jpg new file mode 100644 index 00000000..bd84dcc9 Binary files /dev/null and b/apps/editor/public/material/parquet1/albedoMap_parquet.jpg differ diff --git a/apps/editor/public/material/parquet1/parquet_thumnail.webp b/apps/editor/public/material/parquet1/parquet_thumnail.webp new file mode 100644 index 00000000..b6ab1ec6 Binary files /dev/null and b/apps/editor/public/material/parquet1/parquet_thumnail.webp differ diff --git a/apps/editor/public/material/parquet2/albedoMap_parquet.jpg b/apps/editor/public/material/parquet2/albedoMap_parquet.jpg new file mode 100644 index 00000000..a658de2d Binary files /dev/null and b/apps/editor/public/material/parquet2/albedoMap_parquet.jpg differ diff --git a/apps/editor/public/material/parquet2/parquet2_thumbnail.webp b/apps/editor/public/material/parquet2/parquet2_thumbnail.webp new file mode 100644 index 00000000..0b811270 Binary files /dev/null and b/apps/editor/public/material/parquet2/parquet2_thumbnail.webp differ diff --git a/apps/editor/public/material/wallpaper1/albedoMap_1.webp b/apps/editor/public/material/wallpaper1/albedoMap_1.webp new file mode 100644 index 00000000..f22d01ff Binary files /dev/null and b/apps/editor/public/material/wallpaper1/albedoMap_1.webp differ diff --git a/apps/editor/public/material/wallpaper1/normalMap_NormalMap.webp b/apps/editor/public/material/wallpaper1/normalMap_NormalMap.webp new file mode 100644 index 00000000..54bd308c Binary files /dev/null and b/apps/editor/public/material/wallpaper1/normalMap_NormalMap.webp differ diff --git a/apps/editor/public/material/wallpaper1/wallpaper1_thumbnail.webp b/apps/editor/public/material/wallpaper1/wallpaper1_thumbnail.webp new file mode 100644 index 00000000..f8a7cdb3 Binary files /dev/null and b/apps/editor/public/material/wallpaper1/wallpaper1_thumbnail.webp differ diff --git a/apps/editor/public/material/wallpaper2/albedoMap_5.webp b/apps/editor/public/material/wallpaper2/albedoMap_5.webp new file mode 100644 index 00000000..255c55db Binary files /dev/null and b/apps/editor/public/material/wallpaper2/albedoMap_5.webp differ diff --git a/apps/editor/public/material/wallpaper2/wallpaper2_thumnail.webp b/apps/editor/public/material/wallpaper2/wallpaper2_thumnail.webp new file mode 100644 index 00000000..745e6b62 Binary files /dev/null and b/apps/editor/public/material/wallpaper2/wallpaper2_thumnail.webp differ diff --git a/apps/editor/public/material/wallpaper3/albedoMap_wallpaper3.avif b/apps/editor/public/material/wallpaper3/albedoMap_wallpaper3.avif new file mode 100644 index 00000000..efc96453 Binary files /dev/null and b/apps/editor/public/material/wallpaper3/albedoMap_wallpaper3.avif differ diff --git a/apps/editor/public/material/wallpaper3/wallpaper3_thumbnail.webp b/apps/editor/public/material/wallpaper3/wallpaper3_thumbnail.webp new file mode 100644 index 00000000..b1288a1f Binary files /dev/null and b/apps/editor/public/material/wallpaper3/wallpaper3_thumbnail.webp differ diff --git a/apps/editor/public/material/wood1/albedoMap_basecolor.jpg b/apps/editor/public/material/wood1/albedoMap_basecolor.jpg new file mode 100644 index 00000000..5535070d Binary files /dev/null and b/apps/editor/public/material/wood1/albedoMap_basecolor.jpg differ diff --git a/apps/editor/public/material/wood1/normalMap_normal.jpg b/apps/editor/public/material/wood1/normalMap_normal.jpg new file mode 100644 index 00000000..7b741af5 Binary files /dev/null and b/apps/editor/public/material/wood1/normalMap_normal.jpg differ diff --git a/apps/editor/public/material/wood1/wood1_thumbnail.webp b/apps/editor/public/material/wood1/wood1_thumbnail.webp new file mode 100644 index 00000000..b549a688 Binary files /dev/null and b/apps/editor/public/material/wood1/wood1_thumbnail.webp differ diff --git a/apps/editor/public/material/wood2/albedoMap_Wood.jpg b/apps/editor/public/material/wood2/albedoMap_Wood.jpg new file mode 100644 index 00000000..95d1f4bd Binary files /dev/null and b/apps/editor/public/material/wood2/albedoMap_Wood.jpg differ diff --git a/apps/editor/public/material/wood2/aoMap_Wood.jpg b/apps/editor/public/material/wood2/aoMap_Wood.jpg new file mode 100644 index 00000000..a98e41a6 Binary files /dev/null and b/apps/editor/public/material/wood2/aoMap_Wood.jpg differ diff --git a/apps/editor/public/material/wood2/normalMap_Wood.jpg b/apps/editor/public/material/wood2/normalMap_Wood.jpg new file mode 100644 index 00000000..e58c17a5 Binary files /dev/null and b/apps/editor/public/material/wood2/normalMap_Wood.jpg differ diff --git a/apps/editor/public/material/wood2/wood2_thumbnail.webp b/apps/editor/public/material/wood2/wood2_thumbnail.webp new file mode 100644 index 00000000..9413d27d Binary files /dev/null and b/apps/editor/public/material/wood2/wood2_thumbnail.webp differ diff --git a/apps/editor/public/material/wood3/albedoMap_knotted-timber.jpg b/apps/editor/public/material/wood3/albedoMap_knotted-timber.jpg new file mode 100644 index 00000000..ecd8fe53 Binary files /dev/null and b/apps/editor/public/material/wood3/albedoMap_knotted-timber.jpg differ diff --git a/apps/editor/public/material/wood3/wood3_thumbnail.webp b/apps/editor/public/material/wood3/wood3_thumbnail.webp new file mode 100644 index 00000000..138591d0 Binary files /dev/null and b/apps/editor/public/material/wood3/wood3_thumbnail.webp differ diff --git a/apps/editor/public/material/wood4/albedoMap_oak-stretcher.jpg b/apps/editor/public/material/wood4/albedoMap_oak-stretcher.jpg new file mode 100644 index 00000000..debaa565 Binary files /dev/null and b/apps/editor/public/material/wood4/albedoMap_oak-stretcher.jpg differ diff --git a/apps/editor/public/material/wood4/wood4_thumbnail.webp b/apps/editor/public/material/wood4/wood4_thumbnail.webp new file mode 100644 index 00000000..e1abebd6 Binary files /dev/null and b/apps/editor/public/material/wood4/wood4_thumbnail.webp differ diff --git a/apps/editor/public/material/wood5/albedoMap_3_base_color.webp b/apps/editor/public/material/wood5/albedoMap_3_base_color.webp new file mode 100644 index 00000000..d27f30ac Binary files /dev/null and b/apps/editor/public/material/wood5/albedoMap_3_base_color.webp differ diff --git a/apps/editor/public/material/wood5/aoMap_3_ao.jpg b/apps/editor/public/material/wood5/aoMap_3_ao.jpg new file mode 100644 index 00000000..c41629a6 Binary files /dev/null and b/apps/editor/public/material/wood5/aoMap_3_ao.jpg differ diff --git a/apps/editor/public/material/wood5/normalMap_3_normal.jpg b/apps/editor/public/material/wood5/normalMap_3_normal.jpg new file mode 100644 index 00000000..2b27b346 Binary files /dev/null and b/apps/editor/public/material/wood5/normalMap_3_normal.jpg differ diff --git a/apps/editor/public/material/wood5/wood5_thumnail.webp b/apps/editor/public/material/wood5/wood5_thumnail.webp new file mode 100644 index 00000000..6c255e2d Binary files /dev/null and b/apps/editor/public/material/wood5/wood5_thumnail.webp differ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1325c1ea..663bb44f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,6 +38,16 @@ export { wallTouchesOthers, } from './lib/space-detection' 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 { type ControlValue, diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts new file mode 100644 index 00000000..06218708 --- /dev/null +++ b/packages/core/src/material-library.ts @@ -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 +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 26da7c49..675a0966 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -7,11 +7,23 @@ export { type Collection, type CollectionId, generateCollectionId } from './coll // Material export { DEFAULT_MATERIALS, + MaterialMapPropertiesSchema, + MaterialMapsSchema, MaterialPreset, + MaterialPresetPayloadSchema, MaterialProperties, MaterialSchema, + MaterialTarget, + TextureWrapMode, resolveMaterial, } from './material' +export type { + MaterialMapProperties, + MaterialMaps, + MaterialPresetPayload, + MaterialTarget as MaterialTargetValue, + TextureWrapMode as TextureWrapModeValue, +} from './material' export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { DoorNode, DoorSegment } from './nodes/door' diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index fe9a7a80..3ab96bf8 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -25,6 +25,7 @@ export const MaterialProperties = z.object({ export type MaterialProperties = z.infer export const MaterialSchema = z.object({ + id: z.string().optional(), preset: MaterialPreset.optional(), properties: MaterialProperties.optional(), texture: z @@ -37,6 +38,67 @@ export const MaterialSchema = z.object({ }) export type MaterialSchema = z.infer +export const MaterialTarget = z.enum([ + 'wall', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'fence', + 'slab', + 'ceiling', + 'door', + 'window', +]) +export type MaterialTarget = z.infer + +export const TextureWrapMode = z.enum(['Repeat', 'ClampToEdge', 'MirroredRepeat']) +export type TextureWrapMode = z.infer + +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 + +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 + +export const MaterialPresetPayloadSchema = z.object({ + maps: MaterialMapsSchema, + mapProperties: MaterialMapPropertiesSchema, +}) +export type MaterialPresetPayload = z.infer + export const DEFAULT_MATERIALS: Record = { white: { color: '#ffffff', diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index 21d686a6..70e89e21 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -9,6 +9,7 @@ export const CeilingNode = BaseNode.extend({ type: nodeType('ceiling'), children: z.array(ItemNode.shape.id).default([]), material: MaterialSchema.optional(), + materialPreset: z.string().optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), height: z.number().default(2.5), // Height in meters diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index 3a42e9d8..d1592081 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -1,6 +1,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' export const FenceStyle = z.enum(['slat', 'rail', 'privacy']) export const FenceBaseStyle = z.enum(['floating', 'grounded']) @@ -8,6 +9,8 @@ export const FenceBaseStyle = z.enum(['floating', 'grounded']) export const FenceNode = BaseNode.extend({ id: objectId('fence'), type: nodeType('fence'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), height: z.number().default(1.8), diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 9b43858c..6a43b179 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -11,6 +11,7 @@ export const RoofSegmentNode = BaseNode.extend({ id: objectId('rseg'), type: nodeType('roof-segment'), material: MaterialSchema.optional(), + materialPreset: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index da594da5..d6529b4e 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -8,6 +8,7 @@ export const RoofNode = BaseNode.extend({ id: objectId('roof'), type: nodeType('roof'), material: MaterialSchema.optional(), + materialPreset: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index 765921ce..c98cb165 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -7,6 +7,7 @@ export const SlabNode = BaseNode.extend({ id: objectId('slab'), type: nodeType('slab'), material: MaterialSchema.optional(), + materialPreset: z.string().optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), elevation: z.number().default(0.05), // Elevation in meters diff --git a/packages/core/src/schema/nodes/stair-segment.ts b/packages/core/src/schema/nodes/stair-segment.ts index 241372df..749675a7 100644 --- a/packages/core/src/schema/nodes/stair-segment.ts +++ b/packages/core/src/schema/nodes/stair-segment.ts @@ -15,6 +15,7 @@ export const StairSegmentNode = BaseNode.extend({ id: objectId('sseg'), type: nodeType('stair-segment'), material: MaterialSchema.optional(), + materialPreset: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index 9e3df380..124f25fc 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -16,6 +16,7 @@ export const StairNode = BaseNode.extend({ id: objectId('stair'), type: nodeType('stair'), material: MaterialSchema.optional(), + materialPreset: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 8c25988f..23c1b3ab 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -12,6 +12,7 @@ export const WallNode = BaseNode.extend({ type: nodeType('wall'), children: z.array(ItemNode.shape.id).default([]), material: MaterialSchema.optional(), + materialPreset: z.string().optional(), thickness: z.number().optional(), height: z.number().optional(), // e.g., start/end points for path diff --git a/packages/core/src/systems/fence/fence-system.tsx b/packages/core/src/systems/fence/fence-system.tsx index 7f1de3bd..70d29b74 100644 --- a/packages/core/src/systems/fence/fence-system.tsx +++ b/packages/core/src/systems/fence/fence-system.tsx @@ -10,6 +10,66 @@ type FencePart = { 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']) { if (style === 'privacy') { 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() geometries.forEach((geometry) => geometry.dispose()) + applyFenceUVs(merged) merged.computeVertexNormals() return merged } diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 50c60e4b..41606054 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -18,6 +18,13 @@ import { // Reusable CSG evaluator for better performance 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 // ============================================================================ @@ -205,6 +212,7 @@ export function generateExtrudedWall( // Rotate so extrusion direction (Z) becomes height direction (Y) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() + ensureUv2Attribute(geometry) // Apply CSG subtraction for cutouts (doors/windows) const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) @@ -239,6 +247,7 @@ export function generateExtrudedWall( const resultGeometry = resultBrush.geometry resultGeometry.computeVertexNormals() + ensureUv2Attribute(resultGeometry) return resultGeometry } diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index 90c1feed..ec74cbf1 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -1,73 +1,68 @@ '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' -const PRESET_COLORS: Record = { - white: '#ffffff', - brick: '#8b4513', - concrete: '#808080', - wood: '#deb887', - glass: '#87ceeb', - metal: '#c0c0c0', - plaster: '#f5f5dc', - tile: '#d3d3d3', - marble: '#fafafa', - custom: '#ffffff', -} - -const PRESET_LABELS: Record = { - white: 'White', - brick: 'Brick', - concrete: 'Concrete', - wood: 'Wood', - glass: 'Glass', - metal: 'Metal', - plaster: 'Plaster', - tile: 'Tile', - marble: 'Marble', - custom: 'Custom', -} - type MaterialPickerProps = { + nodeType?: MaterialTarget value?: MaterialSchema - onChange: (material: MaterialSchema) => void + selectedMaterialPreset?: string + onChange?: (material: MaterialSchema) => void + onSelectMaterialPreset?: (materialPreset: string) => void } -export function MaterialPicker({ value, onChange }: MaterialPickerProps) { - const [showCustom, setShowCustom] = useState( - value?.preset === 'custom' || !!value?.properties, - ) +export function MaterialPicker({ + nodeType, + value, + selectedMaterialPreset, + onChange, + onSelectMaterialPreset, +}: MaterialPickerProps) { + const [showCustom, setShowCustom] = useState(!!value?.properties) + const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : [] + + const currentProps = value?.properties || { + 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 currentPreset = value?.preset || 'white' - const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset] + const handleCatalogSelect = (materialId: string) => { + setShowCustom(false) + onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) + } - const handlePresetChange = (preset: MaterialPreset) => { - if (preset === 'custom') { - setShowCustom(true) - onChange({ - preset: 'custom', - properties: { - color: value?.properties?.color || '#ffffff', - roughness: value?.properties?.roughness ?? 0.5, - metalness: value?.properties?.metalness ?? 0, - opacity: value?.properties?.opacity ?? 1, - transparent: value?.properties?.transparent ?? false, - side: value?.properties?.side ?? 'front', - }, - }) - } else { - setShowCustom(false) - onChange({ preset }) - } + const handleCustomOpen = () => { + setShowCustom(true) + onChange?.({ + preset: 'custom', + properties: { + color: value?.properties?.color || '#ffffff', + roughness: value?.properties?.roughness ?? 0.5, + metalness: value?.properties?.metalness ?? 0, + opacity: value?.properties?.opacity ?? 1, + transparent: value?.properties?.transparent ?? false, + side: value?.properties?.side ?? 'front', + }, + }) } const handlePropertyChange = ( prop: keyof typeof currentProps, val: (typeof currentProps)[keyof typeof currentProps], ) => { - onChange({ - preset: showCustom ? 'custom' : currentPreset, + onChange?.({ + preset: 'custom', properties: { ...currentProps, [prop]: val, @@ -77,31 +72,56 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) { return (
-
- {(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => ( -
+ {(catalogItems.length > 0 || onChange) && ( +
+ {catalogItems.length > 0 ? ( +
Library
+ ) : null} +
+ {catalogItems.map((item) => ( + + ))} + {onChange ? ( + + ) : null} +
+
+ )} - {showCustom && ( + {showCustom && onChange && (
diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx index 263609ab..833344ba 100755 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -34,7 +34,14 @@ export function CeilingPanel() { const handleMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material }) + handleUpdate({ material, materialPreset: undefined }) + }, + [handleUpdate], + ) + + const handleMaterialPresetChange = useCallback( + (materialPreset: string) => { + handleUpdate({ materialPreset, material: undefined }) }, [handleUpdate], ) @@ -223,7 +230,13 @@ export function CeilingPanel() { - + ) diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx index a61ff783..2129efc5 100644 --- a/packages/editor/src/components/ui/panels/fence-panel.tsx +++ b/packages/editor/src/components/ui/panels/fence-panel.tsx @@ -1,8 +1,17 @@ '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 { useCallback } from 'react' +import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -62,6 +71,20 @@ export function FencePanel() { setSelection({ selectedIds: [] }) }, [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 const dx = node.end[0] - node.start[0] @@ -179,6 +202,16 @@ export function FencePanel() { value={node.edgeInset} /> + + + + ) } diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index b3ced382..75e59f9d 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -43,7 +43,14 @@ export function RoofPanel() { const handleMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material }) + handleUpdate({ material, materialPreset: undefined }) + }, + [handleUpdate], + ) + + const handleMaterialPresetChange = useCallback( + (materialPreset: string) => { + handleUpdate({ materialPreset, material: undefined }) }, [handleUpdate], ) @@ -255,7 +262,13 @@ export function RoofPanel() { - + ) diff --git a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx index 8df1d687..83780a9e 100755 --- a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx @@ -57,7 +57,14 @@ export function RoofSegmentPanel() { const handleMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material }) + handleUpdate({ material, materialPreset: undefined }) + }, + [handleUpdate], + ) + + const handleMaterialPresetChange = useCallback( + (materialPreset: string) => { + handleUpdate({ materialPreset, material: undefined }) }, [handleUpdate], ) @@ -319,7 +326,13 @@ export function RoofSegmentPanel() { - + ) diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx index 9429aa78..60c53952 100755 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -30,9 +30,16 @@ export function SlabPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( + const handleMaterialPresetChange = useCallback( + (materialPreset: string) => { + handleUpdate({ materialPreset, material: undefined }) + }, + [handleUpdate], + ) + + const handleCustomMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material }) + handleUpdate({ material, materialPreset: undefined }) }, [handleUpdate], ) @@ -221,7 +228,13 @@ export function SlabPanel() {
- + ) diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index c91e9684..0defff49 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -70,7 +70,14 @@ export function StairPanel() { const handleMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material }) + handleUpdate({ material, materialPreset: undefined }) + }, + [handleUpdate], + ) + + const handleMaterialPresetChange = useCallback( + (materialPreset: string) => { + handleUpdate({ materialPreset, material: undefined }) }, [handleUpdate], ) @@ -470,7 +477,13 @@ export function StairPanel() { - + ) diff --git a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx index 98cc4ef7..a1edf3ec 100644 --- a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx @@ -65,7 +65,14 @@ export function StairSegmentPanel() { const handleMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material }) + handleUpdate({ material, materialPreset: undefined }) + }, + [handleUpdate], + ) + + const handleMaterialPresetChange = useCallback( + (materialPreset: string) => { + handleUpdate({ materialPreset, material: undefined }) }, [handleUpdate], ) @@ -332,7 +339,13 @@ export function StairSegmentPanel() { - + ) diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 3af5f402..a26ef377 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -55,9 +55,16 @@ export function WallPanel() { [node, handleUpdate], ) - const handleMaterialChange = useCallback( + const handleMaterialPresetChange = useCallback( + (materialPreset: string) => { + handleUpdate({ materialPreset, material: undefined }) + }, + [handleUpdate], + ) + + const handleCustomMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material }) + handleUpdate({ material, materialPreset: undefined }) }, [handleUpdate], ) @@ -116,7 +123,13 @@ export function WallPanel() { - + ) diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 7c2f37ef..51e17f4a 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -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 { float, mix, positionWorld, smoothstep } from 'three/tsl' 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 materials = useMemo(() => { - const props = resolveMaterial(node.material) + const preset = getMaterialPresetByRef(node.materialPreset) + const props = preset?.mapProperties ?? resolveMaterial(node.material) const color = props.color || '#999999' 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 ( diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx index 008e083d..7f22d5e8 100644 --- a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -2,12 +2,28 @@ import { type FenceNode, useRegistry, useScene } from '@pascal-app/core' import { useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' 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 }) => { const ref = useRef(null!) 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) useLayoutEffect(() => { diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index 327935ce..b24d5b9c 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -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 type * as THREE from 'three' 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 { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const ref = useRef(null!) + const nodes = useScene((state) => state.nodes) useRegistry(node.id, 'roof-segment', ref) const handlers = useNodeEvents(node, 'roof-segment') const debugColors = useViewer((s) => s.debugColors) + const parentNode = + node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined 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 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 diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index c89174f0..776fcbe7 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -2,7 +2,7 @@ import { type RoofNode, useRegistry } from '@pascal-app/core' import { useMemo, useRef } from 'react' import type * as THREE from 'three' 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 { NodeRenderer } from '../node-renderer' import { roofDebugMaterials, roofMaterials } from './roof-materials' @@ -16,10 +16,12 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const debugColors = useViewer((s) => s.debugColors) const customMaterial = useMemo(() => { + const presetMaterial = createMaterialFromPresetRef(node.materialPreset) + if (presetMaterial) return presetMaterial const mat = node.material if (!mat) return null 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 diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index a73d9622..ff7b704a 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -2,7 +2,11 @@ import { type SlabNode, useRegistry } from '@pascal-app/core' import { useMemo, useRef } from 'react' import type { Mesh } from 'three' 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 }) => { const ref = useRef(null!) @@ -12,10 +16,18 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { const handlers = useNodeEvents(node, 'slab') const material = useMemo(() => { + const presetMaterial = createMaterialFromPresetRef(node.materialPreset) + if (presetMaterial) return presetMaterial const mat = node.material if (!mat) return DEFAULT_SLAB_MATERIAL 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 ( { const ref = useRef(null!) + const nodes = useScene((state) => state.nodes) useRegistry(node.id, 'stair-segment', ref) @@ -14,12 +15,30 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'stair-segment') + const parentNode = + node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined 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 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 ( { const handlers = useNodeEvents(node, 'stair') 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.material, node.material?.preset, node.material?.properties, node.material?.texture]) + }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( { @@ -17,10 +21,18 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { const handlers = useNodeEvents(node, 'wall') const material = useMemo(() => { + const presetMaterial = createMaterialFromPresetRef(node.materialPreset) + if (presetMaterial) return presetMaterial const mat = node.material if (!mat) return DEFAULT_WALL_MATERIAL return createMaterial(mat) - }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) + }, [ + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + node.materialPreset, + ]) return ( diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index b4e14c75..e254edba 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -310,6 +310,10 @@ const PostProcessingPasses = () => { bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4) bgUniform.current.value.copy(bgCurrent.current) + if (!isInitialized) { + return + } + if (hasPipelineErrorRef.current || !renderPipelineRef.current) { try { if ((renderer as any).setClearAlpha) { diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 81424157..f51c4dd5 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -281,6 +281,10 @@ export const SelectionManager = () => { if (!strategy) return if (strategy.isValid(event.node)) { event.stopPropagation() + if (event.node.type === 'slab') { + useViewer.setState({ hoveredId: null }) + return + } useViewer.setState({ hoveredId: event.node.id }) } } @@ -388,11 +392,14 @@ const OutlinerSync = () => { const selection = useViewer((s) => s.selection) const hoveredId = useViewer((s) => s.hoveredId) const outliner = useViewer((s) => s.outliner) + const nodes = useScene((s) => s.nodes) useEffect(() => { // Sync selected objects outliner.selectedObjects.length = 0 for (const id of selection.selectedIds) { + const node = nodes[id as AnyNodeId] + if (node?.type === 'slab') continue const obj = sceneRegistry.nodes.get(id) if (obj) outliner.selectedObjects.push(obj) } @@ -400,10 +407,12 @@ const OutlinerSync = () => { // Sync hovered objects outliner.hoveredObjects.length = 0 if (hoveredId) { + const hoveredNode = nodes[hoveredId as AnyNodeId] + if (hoveredNode?.type === 'slab') return const obj = sceneRegistry.nodes.get(hoveredId) if (obj) outliner.hoveredObjects.push(obj) } - }, [selection, hoveredId, outliner]) + }, [selection, hoveredId, outliner, nodes]) return null } diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 8b20f1e4..0aec65dd 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -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' const sideMap: Record = { @@ -8,19 +15,247 @@ const sideMap: Record = { } const materialCache = new Map() +const textureCache = new Map() +const textureLoadPromises = new Map>() +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 { return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}` } +function getTextureKey(material?: MaterialSchema): string { + const texture = material?.texture + 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 { + 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)) { + 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) + const cacheKey = `${getCacheKey(props)}-${getTextureKey(material)}` if (materialCache.has(cacheKey)) { return materialCache.get(cacheKey)! } + const map = getTexture(material) + const threeMaterial = new THREE.MeshStandardMaterial({ color: props.color, roughness: props.roughness, @@ -28,6 +263,7 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat opacity: props.opacity, transparent: props.transparent, side: sideMap[props.side], + map, }) materialCache.set(cacheKey, threeMaterial) @@ -70,4 +306,10 @@ export function clearMaterialCache(): void { material.dispose() } materialCache.clear() + + for (const texture of textureCache.values()) { + texture.dispose() + } + textureCache.clear() + textureLoadPromises.clear() } diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 518d4b6d..b80613d1 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, baseMaterial, emitter, + getMaterialPresetByRef, sceneRegistry, useScene, type WallNode, @@ -13,6 +14,7 @@ import { Color } from 'three' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' import useViewer from '../../store/use-viewer' +import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' const tmpVec = new Vector3() const u = new Vector3() @@ -21,12 +23,14 @@ const DEFAULT_WALL_COLOR = '#f2f0ed' const WALL_HIGHLIGHT_PROFILES = { delete: { color: new Color('#dc2626'), - blend: 0.78, + 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 @@ -51,11 +55,11 @@ const dotPattern = Fn(() => { }) interface WallMaterials { - visible: MeshStandardNodeMaterial + visible: Material invisible: MeshStandardNodeMaterial - deleteVisible: MeshStandardNodeMaterial + deleteVisible: Material deleteInvisible: MeshStandardNodeMaterial - highlightedVisible: MeshStandardNodeMaterial + highlightedVisible: Material highlightedInvisible: MeshStandardNodeMaterial materialHash: string } @@ -75,6 +79,7 @@ const presetColors = { } 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') { @@ -90,27 +95,54 @@ function getPresetColor(preset: string): string { 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] - return new Color(color).lerp(profile.color, profile.blend) + return color.clone().lerp(profile.color, profile.blend) } function createHighlightedWallMaterial( - material: MeshStandardNodeMaterial, - baseColor: string, + material: Material, kind: WallHighlightKind, -): MeshStandardNodeMaterial { - const highlightedMaterial = material.clone() - const highlightedColor = getHighlightedColor(baseColor, kind) +): Material { + const highlightedMaterial = material.clone() as Material & { + color?: Color + emissive?: Color + emissiveIntensity?: number + needsUpdate?: boolean + } const profile = WALL_HIGHLIGHT_PROFILES[kind] - highlightedMaterial.color = highlightedColor - highlightedMaterial.emissive = highlightedColor.clone() - highlightedMaterial.emissiveIntensity = profile.emissiveIntensity + 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) @@ -130,19 +162,16 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials { } 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 } else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') { userColor = getPresetColor(wallNode.material.preset) } - const visibleMat = wallNode.material - ? new MeshStandardNodeMaterial({ - color: userColor, - roughness: 1, - metalness: 0, - }) - : (baseMaterial.clone() as MeshStandardNodeMaterial) + const visibleMat = createBaseVisibleWallMaterial(wallNode) const invisibleMat = new MeshStandardNodeMaterial({ transparent: true, @@ -152,10 +181,13 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials { emissive: userColor, }) - const highlightedVisible = createHighlightedWallMaterial(visibleMat, userColor, 'selection') - const highlightedInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'selection') - const deleteVisible = createHighlightedWallMaterial(visibleMat, userColor, 'delete') - const deleteInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'delete') + 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, @@ -170,6 +202,10 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials { return result } +function getVisibleWallMaterial(wallNode: WallNode): Material { + return createBaseVisibleWallMaterial(wallNode) +} + function getWallHideState( wallNode: WallNode, wallMesh: Mesh, @@ -265,9 +301,7 @@ export const WallCutout = () => { ? materials.deleteVisible : isSelectionHighlighted ? materials.highlightedVisible - : wallNode.material - ? materials.visible - : baseMaterial + : getVisibleWallMaterial(wallNode) } }) lastWallMode.current = wallMode @@ -289,7 +323,7 @@ export const WallCutout = () => { const current = wallMesh.material as Material snapshot.set(wallMesh, current) if (current === mats.highlightedVisible || current === mats.deleteVisible) { - wallMesh.material = mats.visible + wallMesh.material = getVisibleWallMaterial(wallNode) } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { wallMesh.material = mats.invisible }