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..134770e2 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, @@ -59,9 +69,24 @@ export { getWallPlanFootprint, getWallThickness, } from './systems/wall/wall-footprint' +export { + getClampedWallCurveOffset, + getMaxWallCurveOffset, + getWallChordFrame, + getWallCurveFrameAt, + getWallCurveLength, + getWallMidpointHandlePoint, + getWallStraightSnapOffset, + getWallSurfacePolygon, + isCurvedWall, + normalizeWallCurveOffset, + sampleWallCenterline, +} from './systems/wall/wall-curve' export { calculateLevelMiters, + getWallMiterBoundaryPoints, type Point2D, + type WallMiterBoundaryPoints, pointToKey, type WallMiterData, } from './systems/wall/wall-mitering' 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 0466bd13..b93bf9f6 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 1b9ab5a9..ccbec814 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -13,8 +13,10 @@ export const WallNode = BaseNode.extend({ .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) .default([]), material: MaterialSchema.optional(), + materialPreset: z.string().optional(), thickness: z.number().optional(), height: z.number().optional(), + curveOffset: z.number().optional(), // e.g., start/end points for path start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), @@ -26,6 +28,7 @@ export const WallNode = BaseNode.extend({ Wall node - used to represent a wall in the building - thickness: thickness in meters - height: height in meters + - curveOffset: midpoint sagitta offset used to bend the wall into an arc - start: start point of the wall in level coordinate system - end: end point of the wall in level coordinate system - size: size of the wall in grid units diff --git a/packages/core/src/systems/ceiling/ceiling-system.tsx b/packages/core/src/systems/ceiling/ceiling-system.tsx index 6787864a..4f47fab1 100644 --- a/packages/core/src/systems/ceiling/ceiling-system.tsx +++ b/packages/core/src/systems/ceiling/ceiling-system.tsx @@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import type { AnyNodeId, CeilingNode } from '../../schema' import useScene from '../../store/use-scene' +function ensureUv2Attribute(geometry: THREE.BufferGeometry) { + const uv = geometry.getAttribute('uv') + if (!uv) return + + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) +} + // ============================================================================ // CEILING SYSTEM // ============================================================================ @@ -100,6 +107,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG // Rotate so the shape lies flat in X-Z plane geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() + ensureUv2Attribute(geometry) return geometry } 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/slab/slab-system.tsx b/packages/core/src/systems/slab/slab-system.tsx index 8e43df7b..5c75b713 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/core/src/systems/slab/slab-system.tsx @@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import type { AnyNodeId, SlabNode } from '../../schema' import useScene from '../../store/use-scene' +function ensureUv2Attribute(geometry: THREE.BufferGeometry) { + const uv = geometry.getAttribute('uv') + if (!uv) return + + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) +} + // ============================================================================ // SLAB SYSTEM // ============================================================================ @@ -39,6 +46,7 @@ export const SlabSystem = () => { */ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { const newGeo = generateSlabGeometry(node) + ensureUv2Attribute(newGeo) mesh.geometry.dispose() mesh.geometry = newGeo @@ -157,16 +165,46 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { if (polygon.length < 3) return new THREE.BufferGeometry() const positions: number[] = [] + const uvs: number[] = [] const indices: number[] = [] const n = polygon.length + const bounds = new THREE.Box2() + + for (const [x, z] of polygon) { + bounds.expandByPoint(new THREE.Vector2(x, z)) + } + for (const hole of slabNode.holes ?? []) { + for (const [x, z] of hole) { + bounds.expandByPoint(new THREE.Vector2(x, z)) + } + } + + const floorWidth = Math.max(bounds.max.x - bounds.min.x, 0.001) + const floorHeight = Math.max(bounds.max.y - bounds.min.y, 0.001) + + const pushFloorVertex = (x: number, y: number, z: number) => { + positions.push(x, y, z) + uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight) + } + + const pushWallVertex = ( + x: number, + y: number, + z: number, + u: number, + v: number, + ) => { + positions.push(x, y, z) + uvs.push(u, v) + } // --- Floor at Y=0 --- - for (const [x, z] of polygon) positions.push(x!, 0, z!) + for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!) const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!)) const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) for (const hole of slabNode.holes ?? []) { - for (const [x, z] of hole) positions.push(x!, 0, z!) + for (const [x, z] of hole) pushFloorVertex(x!, 0, z!) } const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d) @@ -182,11 +220,12 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { const [x0, z0] = polygon[i]! const [x1, z1] = polygon[j]! const vBase = positions.length / 3 + const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001) - positions.push(x0!, 0, z0!) // v0 — floor level - positions.push(x1!, 0, z1!) // v1 — floor level - positions.push(x1!, depth, z1!) // v2 — ground level - positions.push(x0!, depth, z0!) // v3 — ground level + pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level + pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level + pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level + pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level indices.push(vBase, vBase + 1, vBase + 2) indices.push(vBase, vBase + 2, vBase + 3) @@ -194,6 +233,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) geo.setIndex(indices) geo.computeVertexNormals() return geo diff --git a/packages/core/src/systems/wall/wall-curve.ts b/packages/core/src/systems/wall/wall-curve.ts new file mode 100644 index 00000000..5b6c281c --- /dev/null +++ b/packages/core/src/systems/wall/wall-curve.ts @@ -0,0 +1,230 @@ +import type { Point2D } from './wall-mitering' +import type { WallNode } from '../../schema' + +const CURVE_EPSILON = 1e-6 +const DEFAULT_SAMPLE_SEGMENTS = 24 + +type WallCurveLike = Pick + +type CurveFrame = { + point: Point2D + tangent: Point2D + normal: Point2D +} + +type WallSurfaceMiterOverrides = { + startLeft?: Point2D + startRight?: Point2D + endLeft?: Point2D + endRight?: Point2D +} + +function clamp01(value: number) { + return Math.max(0, Math.min(1, value)) +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t +} + +function distance(a: Point2D, b: Point2D) { + return Math.hypot(b.x - a.x, b.y - a.y) +} + +export function getWallStartPoint(wall: WallCurveLike): Point2D { + return { x: wall.start[0], y: wall.start[1] } +} + +export function getWallEndPoint(wall: WallCurveLike): Point2D { + return { x: wall.end[0], y: wall.end[1] } +} + +export function getWallChordLength(wall: WallCurveLike) { + return distance(getWallStartPoint(wall), getWallEndPoint(wall)) +} + +export function getMaxWallCurveOffset(wall: WallCurveLike) { + return getWallChordLength(wall) / 2 +} + +export function getWallStraightSnapOffset(wall: WallCurveLike) { + return Math.min(0.03, Math.max(0.005, getWallChordLength(wall) * 0.005)) +} + +function clampCurveOffset(wall: WallCurveLike, offset: number) { + const maxOffset = getMaxWallCurveOffset(wall) + if (!Number.isFinite(maxOffset) || maxOffset < CURVE_EPSILON) { + return 0 + } + + return Math.max(-maxOffset, Math.min(maxOffset, offset)) +} + +export function normalizeWallCurveOffset(wall: WallCurveLike, offset: number) { + const clamped = clampCurveOffset(wall, offset) + return Math.abs(clamped) <= getWallStraightSnapOffset(wall) ? 0 : clamped +} + +export function getClampedWallCurveOffset(wall: WallCurveLike) { + const value = wall.curveOffset ?? 0 + const normalized = normalizeWallCurveOffset(wall, value) + return Math.abs(normalized) > CURVE_EPSILON ? normalized : 0 +} + +export function isCurvedWall(wall: WallCurveLike) { + return Math.abs(getClampedWallCurveOffset(wall)) > CURVE_EPSILON +} + +export function getWallChordFrame(wall: WallCurveLike) { + const start = getWallStartPoint(wall) + const end = getWallEndPoint(wall) + const dx = end.x - start.x + const dy = end.y - start.y + const length = Math.hypot(dx, dy) + + if (length < CURVE_EPSILON) { + return { + start, + end, + midpoint: start, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + length: 0, + } + } + + return { + start, + end, + midpoint: { + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2, + }, + tangent: { x: dx / length, y: dy / length }, + normal: { x: -dy / length, y: dx / length }, + length, + } +} + +function getWallArcData(wall: WallCurveLike) { + const chord = getWallChordFrame(wall) + const sagitta = getClampedWallCurveOffset(wall) + + if (Math.abs(sagitta) <= CURVE_EPSILON || chord.length < CURVE_EPSILON) { + return null + } + + const absSagitta = Math.abs(sagitta) + const radius = chord.length * chord.length / (8 * absSagitta) + absSagitta / 2 + const centerOffset = radius - absSagitta + const direction = Math.sign(sagitta) || 1 + const center = { + x: chord.midpoint.x + chord.normal.x * centerOffset * direction, + y: chord.midpoint.y + chord.normal.y * centerOffset * direction, + } + const startAngle = Math.atan2(chord.start.y - center.y, chord.start.x - center.x) + const endAngle = Math.atan2(chord.end.y - center.y, chord.end.x - center.x) + + let delta = endAngle - startAngle + if (direction > 0) { + while (delta <= 0) delta += Math.PI * 2 + } else { + while (delta >= 0) delta -= Math.PI * 2 + } + + return { center, radius, startAngle, delta, direction } +} + +export function getWallCurveFrameAt(wall: WallCurveLike, t: number): CurveFrame { + const chord = getWallChordFrame(wall) + if (!isCurvedWall(wall) || chord.length < CURVE_EPSILON) { + return { + point: { + x: lerp(chord.start.x, chord.end.x, clamp01(t)), + y: lerp(chord.start.y, chord.end.y, clamp01(t)), + }, + tangent: chord.tangent, + normal: chord.normal, + } + } + + const arc = getWallArcData(wall) + if (!arc) { + return { + point: chord.midpoint, + tangent: chord.tangent, + normal: chord.normal, + } + } + + const angle = arc.startAngle + arc.delta * clamp01(t) + const point = { + x: arc.center.x + Math.cos(angle) * arc.radius, + y: arc.center.y + Math.sin(angle) * arc.radius, + } + const tangent = + arc.direction > 0 + ? { x: -Math.sin(angle), y: Math.cos(angle) } + : { x: Math.sin(angle), y: -Math.cos(angle) } + + return { + point, + tangent, + normal: { + x: -tangent.y, + y: tangent.x, + }, + } +} + +export function getWallMidpointHandlePoint(wall: WallCurveLike) { + return getWallCurveFrameAt(wall, 0.5).point +} + +export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { + const count = Math.max(1, segments) + return Array.from({ length: count + 1 }, (_, index) => getWallCurveFrameAt(wall, index / count).point) +} + +export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { + const points = sampleWallCenterline(wall, segments) + let totalLength = 0 + + for (let index = 1; index < points.length; index += 1) { + totalLength += distance(points[index - 1]!, points[index]!) + } + + return totalLength +} + +export function getWallSurfacePolygon( + wall: Pick, + segments = DEFAULT_SAMPLE_SEGMENTS, + miterOverrides?: WallSurfaceMiterOverrides, +) { + const halfThickness = (wall.thickness ?? 0.1) / 2 + const count = Math.max(1, segments) + const left: Point2D[] = [] + const right: Point2D[] = [] + + for (let index = 0; index <= count; index += 1) { + const frame = getWallCurveFrameAt(wall, index / count) + left.push({ + x: frame.point.x + frame.normal.x * halfThickness, + y: frame.point.y + frame.normal.y * halfThickness, + }) + right.push({ + x: frame.point.x - frame.normal.x * halfThickness, + y: frame.point.y - frame.normal.y * halfThickness, + }) + } + + if (left.length > 0 && right.length > 0) { + left[0] = miterOverrides?.startLeft ?? left[0]! + right[0] = miterOverrides?.startRight ?? right[0]! + left[left.length - 1] = miterOverrides?.endLeft ?? left[left.length - 1]! + right[right.length - 1] = miterOverrides?.endRight ?? right[right.length - 1]! + } + + return [...right, ...left.reverse()] +} diff --git a/packages/core/src/systems/wall/wall-footprint.ts b/packages/core/src/systems/wall/wall-footprint.ts index e66c737a..0313699b 100644 --- a/packages/core/src/systems/wall/wall-footprint.ts +++ b/packages/core/src/systems/wall/wall-footprint.ts @@ -1,8 +1,15 @@ import type { WallNode } from '../../schema' -import { type Point2D, pointToKey, type WallMiterData } from './wall-mitering' +import { getWallSurfacePolygon, isCurvedWall } from './wall-curve' +import { + getWallMiterBoundaryPoints, + type Point2D, + pointToKey, + type WallMiterData, +} from './wall-mitering' export const DEFAULT_WALL_THICKNESS = 0.1 export const DEFAULT_WALL_HEIGHT = 2.5 +const CURVED_WALL_SURFACE_SEGMENTS = 24 export function getWallThickness(wallNode: WallNode): number { return wallNode.thickness ?? DEFAULT_WALL_THICKNESS @@ -10,25 +17,38 @@ export function getWallThickness(wallNode: WallNode): number { export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] { const { junctionData } = miterData - const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } const thickness = getWallThickness(wallNode) const halfT = thickness / 2 - const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y } const L = Math.sqrt(v.x * v.x + v.y * v.y) + if (L < 1e-9) { return [] } const nUnit = { x: -v.y / L, y: v.x / L } - const keyStart = pointToKey(wallStart) const keyEnd = pointToKey(wallEnd) - const startJunction = junctionData.get(keyStart)?.get(wallNode.id) const endJunction = junctionData.get(keyEnd)?.get(wallNode.id) + if (isCurvedWall(wallNode)) { + const boundaryPoints = getWallMiterBoundaryPoints(wallNode, miterData) + if (!boundaryPoints) { + return [] + } + + const { startLeft, startRight, endLeft, endRight } = boundaryPoints + + return getWallSurfacePolygon(wallNode, CURVED_WALL_SURFACE_SEGMENTS, { + endLeft, + endRight, + startLeft, + startRight, + }) + } + const pStartLeft: Point2D = startJunction?.left || { x: wallStart.x + nUnit.x * halfT, y: wallStart.y + nUnit.y * halfT, @@ -37,8 +57,6 @@ export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterDat x: wallStart.x - nUnit.x * halfT, y: wallStart.y - nUnit.y * halfT, } - - // Junction offsets are stored relative to the outgoing direction. const pEndLeft: Point2D = endJunction?.right || { x: wallEnd.x + nUnit.x * halfT, y: wallEnd.y + nUnit.y * halfT, diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index 069b7684..f1ab2729 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -1,4 +1,5 @@ import type { WallNode } from '../../schema' +import { getWallCurveFrameAt, isCurvedWall } from './wall-curve' // ============================================================================ // TYPES @@ -9,6 +10,13 @@ export interface Point2D { y: number } +export interface WallMiterBoundaryPoints { + startLeft: Point2D + startRight: Point2D + endLeft: Point2D + endRight: Point2D +} + interface LineEquation { a: number b: number @@ -127,6 +135,70 @@ function findJunctions(walls: WallNode[]): Map { return actualJunctions } +function getWallDirectionFromJunction( + wall: WallNode, + endType: 'start' | 'end' | 'passthrough', +) { + if (endType === 'passthrough') { + return { + x: wall.end[0] - wall.start[0], + y: wall.end[1] - wall.start[1], + } + } + + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) + return endType === 'start' + ? frame.tangent + : { x: -frame.tangent.x, y: -frame.tangent.y } + } + + return endType === 'start' + ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } +} + +function getWallBoundaryFrame( + wall: WallNode, + endType: 'start' | 'end', +) { + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) + return { + point: frame.point, + tangent: + endType === 'start' + ? frame.tangent + : { x: -frame.tangent.x, y: -frame.tangent.y }, + normal: frame.normal, + } + } + + const point = + endType === 'start' + ? { x: wall.start[0], y: wall.start[1] } + : { x: wall.end[0], y: wall.end[1] } + const vector = + endType === 'start' + ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } + const length = Math.hypot(vector.x, vector.y) + + if (length < 1e-9) { + return { + point, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + + return { + point, + tangent: { x: vector.x / length, y: vector.y / length }, + normal: { x: -vector.y / length, y: vector.x / length }, + } +} + // ============================================================================ // MITER CALCULATION (exactly like demo) // ============================================================================ @@ -171,10 +243,7 @@ function calculateJunctionIntersections( } } else { // Normal wall endpoint (start or end) - const v = - endType === 'start' - ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } - : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } + const v = getWallDirectionFromJunction(wall, endType) const L = Math.sqrt(v.x * v.x + v.y * v.y) if (L < 1e-9) continue @@ -264,6 +333,37 @@ export function calculateLevelMiters(walls: WallNode[]): WallMiterData { return { junctionData, junctions } } +export function getWallMiterBoundaryPoints( + wall: WallNode, + miterData: WallMiterData, +): WallMiterBoundaryPoints | null { + const thickness = wall.thickness ?? 0.1 + const halfThickness = thickness / 2 + const startFrame = getWallBoundaryFrame(wall, 'start') + const endFrame = getWallBoundaryFrame(wall, 'end') + const startJunction = miterData.junctionData.get(pointToKey(startFrame.point))?.get(wall.id) + const endJunction = miterData.junctionData.get(pointToKey(endFrame.point))?.get(wall.id) + + return { + startLeft: startJunction?.left ?? { + x: startFrame.point.x + startFrame.normal.x * halfThickness, + y: startFrame.point.y + startFrame.normal.y * halfThickness, + }, + startRight: startJunction?.right ?? { + x: startFrame.point.x - startFrame.normal.x * halfThickness, + y: startFrame.point.y - startFrame.normal.y * halfThickness, + }, + endLeft: endJunction?.right ?? { + x: endFrame.point.x + endFrame.normal.x * halfThickness, + y: endFrame.point.y + endFrame.normal.y * halfThickness, + }, + endRight: endJunction?.left ?? { + x: endFrame.point.x - endFrame.normal.x * halfThickness, + y: endFrame.point.y - endFrame.normal.y * halfThickness, + }, + } +} + /** * Gets wall IDs that share junctions with the given walls */ diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 50c60e4b..062eb1f6 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -8,15 +8,75 @@ import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import useScene from '../../store/use-scene' import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' +import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' import { calculateLevelMiters, getAdjacentWallIds, + getWallMiterBoundaryPoints, type Point2D, type WallMiterData, + pointToKey, } from './wall-mitering' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() +const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 + +function ensureUv2Attribute(geometry: THREE.BufferGeometry) { + const uv = geometry.getAttribute('uv') + if (!uv) return + + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) +} + +function insetCurvedWallBoundaryPointsFor3D( + wall: WallNode, + boundaryPoints: ReturnType, + miterData: WallMiterData, +) { + if (!boundaryPoints || !isCurvedWall(wall)) { + return boundaryPoints + } + + const insetDistance = Math.min( + CURVED_WALL_3D_ENDPOINT_INSET, + Math.max((wall.thickness ?? 0.1) * 0.01, 0.0005), + ) + + if (insetDistance <= 0) { + return boundaryPoints + } + + const next = { ...boundaryPoints } + const startJunction = miterData.junctions.get(pointToKey({ x: wall.start[0], y: wall.start[1] })) + const endJunction = miterData.junctions.get(pointToKey({ x: wall.end[0], y: wall.end[1] })) + + if (startJunction && startJunction.connectedWalls.length > 1) { + const frame = getWallCurveFrameAt(wall, 0) + next.startLeft = { + x: next.startLeft.x + frame.tangent.x * insetDistance, + y: next.startLeft.y + frame.tangent.y * insetDistance, + } + next.startRight = { + x: next.startRight.x + frame.tangent.x * insetDistance, + y: next.startRight.y + frame.tangent.y * insetDistance, + } + } + + if (endJunction && endJunction.connectedWalls.length > 1) { + const frame = getWallCurveFrameAt(wall, 1) + next.endLeft = { + x: next.endLeft.x - frame.tangent.x * insetDistance, + y: next.endLeft.y - frame.tangent.y * insetDistance, + } + next.endRight = { + x: next.endRight.x - frame.tangent.x * insetDistance, + y: next.endRight.y - frame.tangent.y * insetDistance, + } + } + + return next +} // ============================================================================ // WALL SYSTEM @@ -163,7 +223,14 @@ export function generateExtrudedWall( if (L < 1e-9) { return new THREE.BufferGeometry() } - const polyPoints = getWallPlanFootprint(wallNode, miterData) + const boundaryPoints = getWallMiterBoundaryPoints(wallNode, miterData) + const polyPoints = isCurvedWall(wallNode) + ? getWallSurfacePolygon( + wallNode, + 24, + insetCurvedWallBoundaryPointsFor3D(wallNode, boundaryPoints, miterData) ?? undefined, + ) + : getWallPlanFootprint(wallNode, miterData) if (polyPoints.length < 3) { return new THREE.BufferGeometry() } @@ -205,6 +272,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 +307,7 @@ export function generateExtrudedWall( const resultGeometry = resultBrush.geometry resultGeometry.computeVertexNormals() + ensureUv2Attribute(resultGeometry) return resultGeometry } diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index ea6e46b0..1f6a6280 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -38,7 +38,7 @@ const ALLOWED_TYPES = [ 'slab', 'ceiling', ] -const DELETE_ONLY_TYPES = ['wall'] +const DELETE_ONLY_TYPES: string[] = [] const HOLE_TYPES = ['slab', 'ceiling'] export function FloatingActionMenu() { @@ -48,6 +48,7 @@ export function FloatingActionMenu() { const mode = useEditor((s) => s.mode) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const setMovingNode = useEditor((s) => s.setMovingNode) + const setCurvingWall = useEditor((s) => s.setCurvingWall) const setSelection = useViewer((s) => s.setSelection) const setEditingHole = useEditor((s) => s.setEditingHole) @@ -57,6 +58,18 @@ export function FloatingActionMenu() { const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const node = selectedId ? nodes[selectedId as AnyNodeId] : null const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false + const canCurveSelectedWall = + node?.type === 'wall' && + !(node.children ?? []).some((childId) => { + const child = nodes[childId as AnyNodeId] + if (!child) return false + if (child.type === 'door' || child.type === 'window') return true + if (child.type === 'item') { + const attachTo = child.asset?.attachTo + return attachTo === 'wall' || attachTo === 'wall-side' + } + return false + }) useFrame(() => { if (!(selectedId && isValidType && groupRef.current)) return @@ -84,7 +97,10 @@ export function FloatingActionMenu() { node.type === 'item' || node.type === 'window' || node.type === 'door' || + node.type === 'wall' || node.type === 'fence' || + node.type === 'slab' || + node.type === 'ceiling' || node.type === 'roof' || node.type === 'roof-segment' || node.type === 'stair' || @@ -96,6 +112,16 @@ export function FloatingActionMenu() { }, [node, setMovingNode, setSelection], ) + const handleCurve = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!canCurveSelectedWall || !node || node.type !== 'wall') return + sfxEmitter.emit('sfx:item-pick') + setCurvingWall(node) + setSelection({ selectedIds: [] }) + }, + [canCurveSelectedWall, node, setCurvingWall, setSelection], + ) const handleDuplicate = useCallback( (e: React.MouseEvent) => { @@ -263,10 +289,15 @@ export function FloatingActionMenu() { (e: React.MouseEvent) => { e.stopPropagation() if (!selectedId) return + if (node?.type === 'item') { + sfxEmitter.emit('sfx:item-delete') + } else { + sfxEmitter.emit('sfx:structure-delete') + } setSelection({ selectedIds: [] }) useScene.getState().deleteNode(selectedId as AnyNodeId) }, - [selectedId, setSelection], + [node?.type, selectedId, setSelection], ) if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null @@ -283,17 +314,14 @@ export function FloatingActionMenu() { > e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index b9701612..4d303a9a 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -6,11 +6,17 @@ import { type AnyNodeId, type BuildingNode, calculateLevelMiters, + type CeilingNode, DoorNode, emitter, type GridEvent, type GuideNode, + getWallChordFrame, + isCurvedWall, + getWallMidpointHandlePoint, + normalizeWallCurveOffset, getScaledDimensions, + getWallCurveLength, getWallPlanFootprint, type ItemNode, ItemNode as ItemNodeSchema, @@ -235,6 +241,12 @@ type WallEndpointDragState = { currentPoint: WallPlanPoint } +type WallCurveDragState = { + pointerId: number + wallId: WallNode['id'] + currentCurveOffset: number +} + const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const type GuideCorner = (typeof GUIDE_CORNERS)[number] @@ -276,6 +288,11 @@ type WallEndpointDraft = { end: WallPlanPoint } +type WallCurveDraft = { + wallId: WallNode['id'] + curveOffset: number +} + type SlabBoundaryDraft = { slabId: SlabNode['id'] polygon: WallPlanPoint[] @@ -2176,7 +2193,7 @@ function getWallMeasurementOverlay( ): WallMeasurementOverlay | null { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dz) + const length = getWallCurveLength(wall) if (length < 0.1) { return null @@ -2425,13 +2442,19 @@ function buildGridPath( function findClosestWallPoint( point: WallPlanPoint, walls: WallNode[], - maxDistance = 0.5, + options?: { + maxDistance?: number + canUseWall?: (wall: WallNode) => boolean + }, ): { wall: WallNode point: WallPlanPoint t: number normal: [number, number, number] } | null { + const maxDistance = options?.maxDistance ?? 0.5 + const canUseWall = options?.canUseWall + let best: { wall: WallNode point: WallPlanPoint @@ -2441,6 +2464,10 @@ function findClosestWallPoint( let bestDistSq = maxDistance * maxDistance for (const wall of walls) { + if (canUseWall && !canUseWall(wall)) { + continue + } + const [x1, z1] = wall.start const [x2, z2] = wall.end const dx = x2 - x1 @@ -4351,6 +4378,97 @@ const FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({ ) }) +const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLayer({ + curveHandles, + hoveredHandleId, + onHandleHoverChange, + onWallCurvePointerDown, + palette, +}: { + curveHandles: Array<{ + wall: WallNode + point: WallPlanPoint + isActive: boolean + }> + hoveredHandleId: string | null + onHandleHoverChange: (handleId: string | null) => void + onWallCurvePointerDown: (wall: WallNode, event: ReactPointerEvent) => void + palette: FloorplanPalette +}) { + return ( + <> + {curveHandles.map(({ wall, point, isActive }) => { + const handleId = `curve:${wall.id}` + const isHovered = hoveredHandleId === handleId + const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke + const hoverStroke = isActive + ? palette.endpointHandleActiveStroke + : palette.endpointHandleHoverStroke + const svgPoint = toSvgPlanPoint(point) + const radius = isActive ? 0.16 : 0.14 + + return ( + { + event.stopPropagation() + }} + onPointerEnter={() => onHandleHoverChange(handleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + + + + onWallCurvePointerDown(wall, event)} + pointerEvents="all" + r={radius} + stroke="transparent" + strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH} + style={{ cursor: EDITOR_CURSOR }} + vectorEffect="non-scaling-stroke" + /> + + ) + })} + + ) +}) + const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ hoveredHandleId, midpointHandles, @@ -4544,6 +4662,7 @@ export function FloorplanPanel() { const guideInteractionRef = useRef(null) const guideTransformDraftRef = useRef(null) const wallEndpointDragRef = useRef(null) + const wallCurveDragRef = useRef(null) const siteBoundaryDraftRef = useRef(null) const slabBoundaryDraftRef = useRef(null) const zoneBoundaryDraftRef = useRef(null) @@ -4576,6 +4695,7 @@ export function FloorplanPanel() { const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const setMode = useEditor((state) => state.setMode) const movingNode = useEditor((state) => state.movingNode) + const curvingWall = useEditor((state) => state.curvingWall) const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const setPhase = useEditor((state) => state.setPhase) @@ -4680,6 +4800,22 @@ export function FloorplanPanel() { .filter((node): node is SlabNode => node?.type === 'slab') }), ) + const ceilings = useScene( + useShallow((state) => { + if (!levelId) { + return [] as CeilingNode[] + } + + const nextLevelNode = state.nodes[levelId] + if (!nextLevelNode || nextLevelNode.type !== 'level') { + return [] as CeilingNode[] + } + + return nextLevelNode.children + .map((childId) => state.nodes[childId]) + .filter((node): node is CeilingNode => node?.type === 'ceiling') + }), + ) const levelGuides = useScene( useShallow((state) => { if (!levelId) { @@ -4741,6 +4877,7 @@ export function FloorplanPanel() { const [cursorPoint, setCursorPoint] = useState(null) const [floorplanCursorPosition, setFloorplanCursorPosition] = useState(null) const [wallEndpointDraft, setWallEndpointDraft] = useState(null) + const [wallCurveDraft, setWallCurveDraft] = useState(null) const [hoveredOpeningId, setHoveredOpeningId] = useState(null) const [hoveredWallId, setHoveredWallId] = useState(null) const [hoveredSlabId, setHoveredSlabId] = useState(null) @@ -4748,6 +4885,7 @@ export function FloorplanPanel() { const [hoveredStairId, setHoveredStairId] = useState(null) const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) + const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState(null) const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState(null) const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState(null) const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState(null) @@ -4922,29 +5060,42 @@ export function FloorplanPanel() { [floorplanWalls], ) const displayWallById = useMemo(() => { - if (!wallEndpointDraft) { - return wallById - } - - const wall = wallById.get(wallEndpointDraft.wallId) - if (!wall) { + if (!wallEndpointDraft && !wallCurveDraft) { return wallById } const nextWallById = new Map(wallById) - nextWallById.set( - wall.id, - buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end), - ) + + if (wallEndpointDraft) { + const wall = nextWallById.get(wallEndpointDraft.wallId) + if (wall) { + nextWallById.set( + wall.id, + buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end), + ) + } + } + + if (wallCurveDraft) { + const wall = nextWallById.get(wallCurveDraft.wallId) + if (wall) { + nextWallById.set(wall.id, { ...wall, curveOffset: wallCurveDraft.curveOffset }) + } + } return nextWallById - }, [wallById, wallEndpointDraft]) + }, [wallById, wallCurveDraft, wallEndpointDraft]) const displayFloorplanWallById = useMemo(() => { - if (!wallEndpointDraft) { + if (!wallEndpointDraft && !wallCurveDraft) { return floorplanWallById } - const previewWall = displayWallById.get(wallEndpointDraft.wallId) + const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId + if (!previewWallId) { + return floorplanWallById + } + + const previewWall = displayWallById.get(previewWallId) if (!previewWall) { return floorplanWallById } @@ -4952,7 +5103,7 @@ export function FloorplanPanel() { const nextFloorplanWallById = new Map(floorplanWallById) nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall)) return nextFloorplanWallById - }, [displayWallById, floorplanWallById, wallEndpointDraft]) + }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft]) const wallPolygons = useMemo( () => walls.map((wall) => { @@ -4967,11 +5118,16 @@ export function FloorplanPanel() { [floorplanWallById, wallMiterData, walls], ) const displayWallPolygons = useMemo(() => { - if (!wallEndpointDraft) { + if (!wallEndpointDraft && !wallCurveDraft) { return wallPolygons } - const previewWall = displayWallById.get(wallEndpointDraft.wallId) + const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId + if (!previewWallId) { + return wallPolygons + } + + const previewWall = displayWallById.get(previewWallId) if (!previewWall) { return wallPolygons } @@ -4990,7 +5146,7 @@ export function FloorplanPanel() { } : entry, ) - }, [displayWallById, wallEndpointDraft, wallPolygons]) + }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons]) const openingsPolygons = useMemo( () => @@ -5046,6 +5202,29 @@ export function FloorplanPanel() { : entry, ) }, [slabBoundaryDraft, slabPolygons]) + const ceilingPolygons = useMemo( + () => + ceilings.flatMap((ceiling) => { + const polygon = toFloorplanPolygon(ceiling.polygon) + if (polygon.length < 3) { + return [] + } + + const holes = (ceiling.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3) + + return [ + { + ceiling, + polygon, + holes, + path: formatPolygonPath(polygon, holes), + }, + ] + }), + [ceilings], + ) const zonePolygons = useMemo( () => zones.flatMap((zone) => { @@ -5176,6 +5355,13 @@ export function FloorplanPanel() { return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null }, [floorplanItemEntries, selectedIds]) + const selectedWallEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return displayWallPolygons.find(({ wall }) => wall.id === selectedIds[0]) ?? null + }, [displayWallPolygons, selectedIds]) const selectedStairEntry = useMemo(() => { if (selectedIds.length !== 1) { return null @@ -5192,6 +5378,13 @@ export function FloorplanPanel() { return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null }, [displaySlabPolygons, selectedIds]) + const selectedCeilingEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return ceilingPolygons.find(({ ceiling }) => ceiling.id === selectedIds[0]) ?? null + }, [ceilingPolygons, selectedIds]) const selectedZoneEntry = useMemo(() => { if (!selectedZoneId) { return null @@ -5212,12 +5405,23 @@ export function FloorplanPanel() { const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair' const isStairMoveActive = movingNode?.type === 'stair' + const isSlabMoveActive = movingNode?.type === 'slab' + const isCeilingMoveActive = movingNode?.type === 'ceiling' + const isWallMoveActive = movingNode?.type === 'wall' + const isWallCurveActive = curvingWall?.type === 'wall' const isItemPlacementPreviewActive = (mode === 'build' && tool === 'item') || movingNode?.type === 'item' const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo const isFloorplanGridInteractionActive = - isStairBuildActive || isStairMoveActive || isFloorItemBuildActive || isFloorItemMoveActive + isStairBuildActive || + isStairMoveActive || + isSlabMoveActive || + isCeilingMoveActive || + isWallMoveActive || + isWallCurveActive || + isFloorItemBuildActive || + isFloorItemMoveActive const floorplanPreviewStairSegment = useMemo( () => StairSegmentNodeSchema.parse({ @@ -5399,6 +5603,56 @@ export function FloorplanPanel() { shouldShowPersistentWallEndpointHandles, wallEndpointDraft, ]) + const wallCurveHandles = useMemo(() => { + if ( + isOpeningPlacementActive || + movingNode || + mode !== 'select' || + floorplanSelectionTool !== 'click' || + !selectedWallEntry + ) { + return [] + } + + const hasWallChildrenBlockingCurve = (selectedWallEntry.wall.children ?? []).some((childId) => { + const childNode = levelDescendantNodeById.get(childId as AnyNodeId) + if (!childNode) { + return false + } + + if (childNode.type === 'door' || childNode.type === 'window') { + return true + } + + if (childNode.type === 'item') { + const attachTo = childNode.asset?.attachTo + return attachTo === 'wall' || attachTo === 'wall-side' + } + + return false + }) + if (hasWallChildrenBlockingCurve) { + return [] + } + + const centerPoint = getWallMidpointHandlePoint(selectedWallEntry.wall) + + return [ + { + wall: selectedWallEntry.wall, + point: [centerPoint.x, centerPoint.y] as WallPlanPoint, + isActive: wallCurveDraft?.wallId === selectedWallEntry.wall.id, + }, + ] + }, [ + floorplanSelectionTool, + isOpeningPlacementActive, + mode, + movingNode, + levelDescendantNodeById, + selectedWallEntry, + wallCurveDraft, + ]) const slabVertexHandles = useMemo(() => { if (!shouldShowSlabBoundaryHandles) { return [] @@ -5717,6 +5971,27 @@ export function FloorplanPanel() { : null, [selectedItemEntry, surfaceSize, viewBox], ) + const selectedSlabActionMenuPosition = useMemo( + () => + selectedSlabEntry + ? getFloorplanActionMenuPosition(selectedSlabEntry.polygon, viewBox, surfaceSize) + : null, + [selectedSlabEntry, surfaceSize, viewBox], + ) + const selectedCeilingActionMenuPosition = useMemo( + () => + selectedCeilingEntry + ? getFloorplanActionMenuPosition(selectedCeilingEntry.polygon, viewBox, surfaceSize) + : null, + [selectedCeilingEntry, surfaceSize, viewBox], + ) + const selectedWallActionMenuPosition = useMemo( + () => + selectedWallEntry + ? getFloorplanActionMenuPosition(selectedWallEntry.polygon, viewBox, surfaceSize) + : null, + [selectedWallEntry, surfaceSize, viewBox], + ) const selectedStairActionMenuPosition = useMemo( () => selectedStairEntry @@ -6198,6 +6473,11 @@ export function FloorplanPanel() { setWallEndpointDraft(null) setHoveredEndpointId(null) }, []) + const clearWallCurveDrag = useCallback(() => { + wallCurveDragRef.current = null + setWallCurveDraft(null) + setHoveredWallCurveHandleId(null) + }, []) const clearSiteBoundaryInteraction = useCallback(() => { setSiteVertexDragState(null) setSiteBoundaryDraft(null) @@ -6219,11 +6499,13 @@ export function FloorplanPanel() { clearSlabPlacementDraft() clearZonePlacementDraft() clearWallEndpointDrag() + clearWallCurveDrag() clearSiteBoundaryInteraction() clearSlabBoundaryInteraction() clearZoneBoundaryInteraction() setCursorPoint(null) }, [ + clearWallCurveDrag, clearSiteBoundaryInteraction, clearSlabBoundaryInteraction, clearSlabPlacementDraft, @@ -6430,51 +6712,86 @@ export function FloorplanPanel() { } const dragState = wallEndpointDragRef.current - if (!dragState || event.pointerId !== dragState.pointerId) { + if (dragState && event.pointerId === dragState.pointerId) { + event.preventDefault() + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } + + const snappedPoint = snapWallDraftPoint({ + point: planPoint, + walls, + start: dragState.fixedPoint, + angleSnap: !shiftPressed, + ignoreWallIds: [dragState.wallId], + }) + + if (pointsEqual(dragState.currentPoint, snappedPoint)) { + return + } + + dragState.currentPoint = snappedPoint + setCursorPoint(snappedPoint) + setWallEndpointDraft((previousDraft) => { + const nextDraft = buildWallEndpointDraft( + dragState.wallId, + dragState.endpoint, + dragState.fixedPoint, + snappedPoint, + ) + + if ( + !( + previousDraft && + pointsEqual(previousDraft.start, nextDraft.start) && + pointsEqual(previousDraft.end, nextDraft.end) + ) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + + return nextDraft + }) + return + } + + const curveDragState = wallCurveDragRef.current + if (!curveDragState || event.pointerId !== curveDragState.pointerId) { return } event.preventDefault() const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) - if (!planPoint) { + const wall = wallById.get(curveDragState.wallId) + if (!(planPoint && wall)) { return } - const snappedPoint = snapWallDraftPoint({ - point: planPoint, - walls, - start: dragState.fixedPoint, - angleSnap: !shiftPressed, - ignoreWallIds: [dragState.wallId], - }) - - if (pointsEqual(dragState.currentPoint, snappedPoint)) { - return - } - - dragState.currentPoint = snappedPoint - setCursorPoint(snappedPoint) - setWallEndpointDraft((previousDraft) => { - const nextDraft = buildWallEndpointDraft( - dragState.wallId, - dragState.endpoint, - dragState.fixedPoint, - snappedPoint, + const chord = getWallChordFrame(wall) + const snappedPoint: WallPlanPoint = shiftPressed + ? planPoint + : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const rawCurveOffset = + -( + (snappedPoint[0] - chord.midpoint.x) * chord.normal.x + + (snappedPoint[1] - chord.midpoint.y) * chord.normal.y ) + const nextCurveOffset = normalizeWallCurveOffset( + wall, + shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset), + ) - if ( - !( - previousDraft && - pointsEqual(previousDraft.start, nextDraft.start) && - pointsEqual(previousDraft.end, nextDraft.end) - ) - ) { - sfxEmitter.emit('sfx:grid-snap') - } + if (curveDragState.currentCurveOffset === nextCurveOffset) { + return + } - return nextDraft - }) + curveDragState.currentCurveOffset = nextCurveOffset + setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset }) + setCursorPoint(snappedPoint) + sfxEmitter.emit('sfx:grid-snap') } const commitGuideInteraction = (event: PointerEvent) => { @@ -6559,6 +6876,26 @@ export function FloorplanPanel() { setCursorPoint(null) } + const commitWallCurveDrag = (event: PointerEvent) => { + const dragState = wallCurveDragRef.current + if (!dragState || event.pointerId !== dragState.pointerId) { + return + } + + const wall = wallById.get(dragState.wallId) + if (wall) { + const nextCurveOffset = normalizeWallCurveOffset(wall, dragState.currentCurveOffset) + const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0) + if (nextCurveOffset !== currentCurveOffset) { + updateNode(wall.id, { curveOffset: nextCurveOffset }) + sfxEmitter.emit('sfx:structure-build') + } + } + + clearWallCurveDrag() + setCursorPoint(null) + } + const cancelWallEndpointDrag = (event: PointerEvent) => { const dragState = wallEndpointDragRef.current if (!dragState || event.pointerId !== dragState.pointerId) { @@ -6569,11 +6906,23 @@ export function FloorplanPanel() { setCursorPoint(null) } + const cancelWallCurveDrag = (event: PointerEvent) => { + const dragState = wallCurveDragRef.current + if (!dragState || event.pointerId !== dragState.pointerId) { + return + } + + clearWallCurveDrag() + setCursorPoint(null) + } + window.addEventListener('pointermove', handleWindowPointerMove) window.addEventListener('pointerup', commitGuideInteraction) window.addEventListener('pointercancel', cancelGuideInteraction) window.addEventListener('pointerup', commitWallEndpointDrag) window.addEventListener('pointercancel', cancelWallEndpointDrag) + window.addEventListener('pointerup', commitWallCurveDrag) + window.addEventListener('pointercancel', cancelWallCurveDrag) return () => { window.removeEventListener('pointermove', handleWindowPointerMove) @@ -6581,8 +6930,11 @@ export function FloorplanPanel() { window.removeEventListener('pointercancel', cancelGuideInteraction) window.removeEventListener('pointerup', commitWallEndpointDrag) window.removeEventListener('pointercancel', cancelWallEndpointDrag) + window.removeEventListener('pointerup', commitWallCurveDrag) + window.removeEventListener('pointercancel', cancelWallCurveDrag) } }, [ + clearWallCurveDrag, clearGuideInteraction, clearWallEndpointDrag, getSvgPointFromClientPoint, @@ -6596,7 +6948,8 @@ export function FloorplanPanel() { useEffect(() => { clearWallEndpointDrag() - }, [clearWallEndpointDrag, levelId]) + clearWallCurveDrag() + }, [clearWallCurveDrag, clearWallEndpointDrag, levelId]) useEffect(() => { if (shouldShowSiteBoundaryHandles) { @@ -7066,7 +7419,9 @@ export function FloorplanPanel() { } if (isOpeningPlacementActive) { - const closest = findClosestWallPoint(planPoint, walls) + const closest = findClosestWallPoint(planPoint, walls, { + canUseWall: (wall) => !isCurvedWall(wall), + }) if (closest) { const dx = closest.wall.end[0] - closest.wall.start[0] const dz = closest.wall.end[1] - closest.wall.start[1] @@ -7286,7 +7641,9 @@ export function FloorplanPanel() { } if (isOpeningPlacementActive) { - const closest = findClosestWallPoint(planPoint, walls) + const closest = findClosestWallPoint(planPoint, walls, { + canUseWall: (wall) => !isCurvedWall(wall), + }) if (closest) { const dx = closest.wall.end[0] - closest.wall.start[0] const dz = closest.wall.end[1] - closest.wall.start[1] @@ -7373,7 +7730,9 @@ export function FloorplanPanel() { isOpeningPlacementActive, isPolygonBuildActive, isWallBuildActive, + isWindowBuildActive, isZoneBuildActive, + movingOpeningType, setSelectedReferenceId, setSelection, shiftPressed, @@ -8095,6 +8454,96 @@ export function FloorplanPanel() { }, [deleteNode, selectedItemEntry, setSelection], ) + const handleSelectedWallMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const wall = selectedWallEntry?.wall + if (!wall) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(wall) + setSelection({ selectedIds: [] }) + }, + [selectedWallEntry, setMovingNode, setSelection], + ) + const handleSelectedWallDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const wall = selectedWallEntry?.wall + if (!wall) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(wall.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedWallEntry, setSelection], + ) + const handleSelectedSlabMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const slab = selectedSlabEntry?.slab + if (!slab) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(slab) + setSelection({ selectedIds: [] }) + }, + [selectedSlabEntry, setMovingNode, setSelection], + ) + const handleSelectedSlabDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const slab = selectedSlabEntry?.slab + if (!slab) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(slab.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedSlabEntry, setSelection], + ) + const handleSelectedCeilingMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const ceiling = selectedCeilingEntry?.ceiling + if (!ceiling) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(ceiling) + setSelection({ selectedIds: [] }) + }, + [selectedCeilingEntry, setMovingNode, setSelection], + ) + const handleSelectedCeilingDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const ceiling = selectedCeilingEntry?.ceiling + if (!ceiling) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(ceiling.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedCeilingEntry, setSelection], + ) const handleStairDoubleClick = useCallback( (stair: StairNode, event: ReactMouseEvent) => { emitFloorplanNodeClick(stair.id, 'double-click', event) @@ -8304,6 +8753,45 @@ export function FloorplanPanel() { }, [clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode], ) + const handleWallCurvePointerDown = useCallback( + (wall: WallNode, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredWallCurveHandleId(null) + + if (isWallBuildActive || mode !== 'select') { + return + } + + clearWallPlacementDraft() + handleWallSelect(wall) + clearWallEndpointDrag() + + const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0) + wallCurveDragRef.current = { + pointerId: event.pointerId, + wallId: wall.id, + currentCurveOffset, + } + setWallCurveDraft({ + wallId: wall.id, + curveOffset: currentCurveOffset, + }) + const center = getWallMidpointHandlePoint(wall) + setCursorPoint([center.x, center.y]) + }, + [ + clearWallEndpointDrag, + clearWallPlacementDraft, + handleWallSelect, + isWallBuildActive, + mode, + ], + ) const handleSlabVertexPointerDown = useCallback( (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { @@ -8662,12 +9150,21 @@ export function FloorplanPanel() { !zoneVertexDragState ) { const rect = event.currentTarget.getBoundingClientRect() - setFloorplanCursorPosition({ + const nextPosition = { x: event.clientX - rect.left, y: event.clientY - rect.top, - }) + } + setFloorplanCursorPosition((currentPosition) => + currentPosition && + currentPosition.x === nextPosition.x && + currentPosition.y === nextPosition.y + ? currentPosition + : nextPosition, + ) } else { - setFloorplanCursorPosition(null) + setFloorplanCursorPosition((currentPosition) => + currentPosition === null ? currentPosition : null, + ) } handlePointerMove(event) @@ -9230,7 +9727,7 @@ export function FloorplanPanel() { rotationModifierPressed={rotationModifierPressed} /> )} - {selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && ( + {selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
)} - {selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && ( + {selectedWallActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && ( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
+ )} + {selectedSlabActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && ( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
+ )} + {selectedCeilingActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && ( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
+ )} + {selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
)} - {selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && ( + {selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
+ onDuplicate?: MouseEventHandler onMove?: MouseEventHandler + onCurve?: MouseEventHandler onPointerDown?: PointerEventHandler onPointerUp?: PointerEventHandler onPointerEnter?: PointerEventHandler @@ -20,6 +21,7 @@ export function NodeActionMenu({ onDelete, onDuplicate, onMove, + onCurve, onPointerDown, onPointerUp, onPointerEnter, @@ -44,6 +46,17 @@ export function NodeActionMenu({ )} + {onCurve && ( + + )} {onDuplicate && (
+ {(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..13e56dd6 100755 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -2,10 +2,11 @@ import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Edit, Plus, Trash2 } from 'lucide-react' +import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' +import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' -import { ActionButton } from '../controls/action-button' +import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' @@ -18,6 +19,7 @@ export function CeilingPanel() { const updateNode = useScene((s) => s.updateNode) const editingHole = useEditor((s) => s.editingHole) const setEditingHole = useEditor((s) => s.setEditingHole) + const setMovingNode = useEditor((s) => s.setMovingNode) const selectedId = selectedIds[0] const node = selectedId @@ -34,7 +36,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], ) @@ -102,6 +111,13 @@ export function CeilingPanel() { [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], ) + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null const calculateArea = (polygon: Array<[number, number]>): number => { @@ -223,8 +239,17 @@ export function CeilingPanel() { - + + + } label="Move" onClick={handleMove} /> + ) } 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..11896ab0 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -17,7 +17,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' -import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -43,7 +42,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], ) @@ -166,7 +172,7 @@ 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..1dedff80 100755 --- a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx @@ -16,7 +16,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' -import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -57,7 +56,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], ) @@ -230,7 +236,7 @@ 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..54fd6d9c 100755 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -2,8 +2,9 @@ import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Edit, Plus, Trash2 } from 'lucide-react' +import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' +import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' @@ -18,6 +19,7 @@ export function SlabPanel() { const updateNode = useScene((s) => s.updateNode) const editingHole = useEditor((s) => s.editingHole) const setEditingHole = useEditor((s) => s.setEditingHole) + const setMovingNode = useEditor((s) => s.setMovingNode) const selectedId = selectedIds[0] const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined @@ -30,9 +32,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], ) @@ -100,6 +109,13 @@ export function SlabPanel() { [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], ) + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null const calculateArea = (polygon: Array<[number, number]>): number => { @@ -221,8 +237,17 @@ export function SlabPanel() {
- + + + } label="Move" onClick={handleMove} /> + ) } diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index c91e9684..ef778882 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -21,7 +21,6 @@ import useEditor from '../../../store/use-editor' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' -import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -70,7 +69,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], ) @@ -361,7 +367,7 @@ 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..b3556c95 100644 --- a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx @@ -17,7 +17,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' -import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -65,7 +64,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], ) @@ -243,7 +249,7 @@ 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..64a3e30b 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -3,12 +3,20 @@ import { type AnyNode, type AnyNodeId, + getClampedWallCurveOffset, + getMaxWallCurveOffset, + getWallCurveLength, + normalizeWallCurveOffset, type MaterialSchema, useScene, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { Move, Spline } from 'lucide-react' import { useCallback } from 'react' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' @@ -19,6 +27,8 @@ export function WallPanel() { const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) const updateNode = useScene((s) => s.updateNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + const setCurvingWall = useEditor((s) => s.setCurvingWall) const selectedId = selectedIds[0] const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined @@ -55,9 +65,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], ) @@ -66,14 +83,40 @@ export function WallPanel() { setSelection({ selectedIds: [] }) }, [setSelection]) + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + + const handleCurve = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setCurvingWall(node) + setSelection({ selectedIds: [] }) + }, [node, setCurvingWall, setSelection]) + if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null const dx = node.end[0] - node.start[0] const dz = node.end[1] - node.start[1] - const length = Math.sqrt(dx * dx + dz * dz) + const length = getWallCurveLength(node) const height = node.height ?? 2.5 const thickness = node.thickness ?? 0.1 + const curveOffset = getClampedWallCurveOffset(node) + const maxCurveOffset = getMaxWallCurveOffset(node) + const hasWallChildrenBlockingCurve = (node.children ?? []).some((childId) => { + const child = nodes[childId as AnyNodeId] + if (!child) return false + if (child.type === 'door' || child.type === 'window') return true + if (child.type === 'item') { + const attachTo = child.asset?.attachTo + return attachTo === 'wall' || attachTo === 'wall-side' + } + return false + }) return ( + {!hasWallChildrenBlockingCurve && ( + handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })} + precision={2} + step={0.01} + unit="m" + value={Math.round(curveOffset * 100) / 100} + /> + )} - + + + + } label="Move" onClick={handleMove} /> + {!hasWallChildrenBlockingCurve && ( + } + label="Curve" + onClick={handleCurve} + /> + )} + ) } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 6ace07b8..73b83021 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -3,16 +3,19 @@ import type { AssetInput } from '@pascal-app/core' import { type BuildingNode, + type CeilingNode, type DoorNode, type FenceNode, type ItemNode, type LevelNode, type RoofNode, type RoofSegmentNode, + type SlabNode, type Space, type StairNode, type StairSegmentNode, useScene, + type WallNode, type WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -88,6 +91,9 @@ type EditorState = { | WindowNode | DoorNode | FenceNode + | CeilingNode + | SlabNode + | WallNode | RoofNode | RoofSegmentNode | StairNode @@ -100,6 +106,9 @@ type EditorState = { | WindowNode | DoorNode | FenceNode + | CeilingNode + | SlabNode + | WallNode | RoofNode | RoofSegmentNode | StairNode @@ -107,6 +116,8 @@ type EditorState = { | BuildingNode | null, ) => void + curvingWall: WallNode | null + setCurvingWall: (wall: WallNode | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -437,6 +448,8 @@ const useEditor = create()( | BuildingNode | null, setMovingNode: (node) => set({ movingNode: node }), + curvingWall: null, + setCurvingWall: (wall) => set({ curvingWall: wall }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, 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..537433fe 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -1,8 +1,13 @@ import { type SlabNode, useRegistry } from '@pascal-app/core' -import { useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' 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 +17,34 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { const handlers = useNodeEvents(node, 'slab') const material = useMemo(() => { - const mat = node.material - if (!mat) return DEFAULT_SLAB_MATERIAL - return createMaterial(mat) - }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) + const presetMaterial = createMaterialFromPresetRef(node.materialPreset) + const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL) + const slabMaterial = sourceMaterial.clone() + + // Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids + // pipeline variants that can fail when geometry is regenerated while a + // transparent/custom material is attached. + slabMaterial.transparent = false + slabMaterial.opacity = 1 + slabMaterial.alphaMap = null + slabMaterial.side = THREE.DoubleSide + slabMaterial.depthWrite = true + slabMaterial.needsUpdate = true + + return slabMaterial + }, [ + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + node.materialPreset, + ]) + + useEffect(() => { + return () => { + material.dispose() + } + }, [material]) 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/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}` } -export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial { - const props = resolveMaterial(material) - const cacheKey = getCacheKey(props) +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)}-${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 }