Merge branch 'main' of github.com:sudhir9297/editor into feat/wall-room-creation

This commit is contained in:
sudhir
2026-04-15 22:59:36 +05:30
90 changed files with 3757 additions and 300 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+25
View File
@@ -38,6 +38,16 @@ export {
wallTouchesOthers, wallTouchesOthers,
} from './lib/space-detection' } from './lib/space-detection'
export { baseMaterial, glassMaterial } from './materials' export { baseMaterial, glassMaterial } from './materials'
export {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getMaterialPresetByRef,
getMaterialsForTarget,
LIBRARY_MATERIAL_REF_PREFIX,
MATERIAL_CATALOG,
type MaterialCatalogItem,
toLibraryMaterialRef,
} from './material-library'
export * from './schema' export * from './schema'
export { export {
type ControlValue, type ControlValue,
@@ -59,9 +69,24 @@ export {
getWallPlanFootprint, getWallPlanFootprint,
getWallThickness, getWallThickness,
} from './systems/wall/wall-footprint' } from './systems/wall/wall-footprint'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallCurveFrameAt,
getWallCurveLength,
getWallMidpointHandlePoint,
getWallStraightSnapOffset,
getWallSurfacePolygon,
isCurvedWall,
normalizeWallCurveOffset,
sampleWallCenterline,
} from './systems/wall/wall-curve'
export { export {
calculateLevelMiters, calculateLevelMiters,
getWallMiterBoundaryPoints,
type Point2D, type Point2D,
type WallMiterBoundaryPoints,
pointToKey, pointToKey,
type WallMiterData, type WallMiterData,
} from './systems/wall/wall-mitering' } from './systems/wall/wall-mitering'
+627
View File
@@ -0,0 +1,627 @@
import {
type MaterialPresetPayload,
type MaterialTarget,
MaterialTarget as MaterialTargetSchema,
} from './schema/material'
export type MaterialCatalogItem = {
id: string
label: string
description?: string
targets: MaterialTarget[]
previewThumbnailUrl?: string
previewColor?: string
preset: MaterialPresetPayload
}
const WALL_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.wall,
]
const SLAB_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.slab,
]
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.wall,
MaterialTargetSchema.enum.slab,
]
const STAIR_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
]
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
...STAIR_TARGETS,
MaterialTargetSchema.enum.fence,
]
const ROOF_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.roof,
MaterialTargetSchema.enum['roof-segment'],
]
const CEILING_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.ceiling,
]
export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-wood1',
label: 'Wood',
description: 'Warm wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wood1/albedoMap_basecolor.jpg',
normalMap: '/material/wood1/normalMap_normal.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.575,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wood2',
label: 'Wood',
description: 'Textured wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wood2/albedoMap_Wood.jpg',
normalMap: '/material/wood2/normalMap_Wood.jpg',
aoMap: '/material/wood2/aoMap_Wood.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.467,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 2,
normalScaleY: 2,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 2,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wood3',
label: 'Wood',
description: 'Knotted timber finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wood3/albedoMap_knotted-timber.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.489,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 0.2,
normalScaleY: 0.2,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 2,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wood4',
label: 'Wood',
description: 'Oak stretcher finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wood4/albedoMap_oak-stretcher.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.378,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wood5',
label: 'Wood',
description: 'Rich grain wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
preset: {
maps: {
albedoMap: '/material/wood5/albedoMap_3_base_color.webp',
normalMap: '/material/wood5/normalMap_3_normal.jpg',
aoMap: '/material/wood5/aoMap_3_ao.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.6,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 10,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-granite1',
label: 'Granite',
description: 'Polished granite finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/granite1/albedoMap_Granite.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.189,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-marble1',
label: 'Marble',
description: 'Smooth marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/marble1/albedoMap_marble.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.133,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-marble2',
label: 'Marble',
description: 'Soft marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/marble2/albedoMap_marble.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.122,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-parquet1',
label: 'Parquet',
description: 'Parquet wood finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
preset: {
maps: {
albedoMap: '/material/parquet1/albedoMap_parquet.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.644,
metalness: 0.4,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-parquet2',
label: 'Parquet',
description: 'Soft parquet finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/parquet2/albedoMap_parquet.jpg',
},
mapProperties: {
color: '#ffffff',
roughness: 0.6,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wallpaper1',
label: 'Wallpaper',
description: 'Soft wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wallpaper1/albedoMap_1.webp',
normalMap: '/material/wallpaper1/normalMap_NormalMap.webp',
},
mapProperties: {
color: '#ffffff',
roughness: 0.911,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wallpaper2',
label: 'Wallpaper',
description: 'Decorative wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
preset: {
maps: {
albedoMap: '/material/wallpaper2/albedoMap_5.webp',
},
mapProperties: {
color: '#ffffff',
roughness: 0.889,
metalness: 0.255,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'wall-wallpaper3',
label: 'Wallpaper',
description: 'Patterned wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
preset: {
maps: {
albedoMap: '/material/wallpaper3/albedoMap_wallpaper3.avif',
},
mapProperties: {
color: '#ffffff',
roughness: 0.887,
metalness: 0.35,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'preset-white',
label: 'White',
description: 'Clean painted finish',
targets: [
...WALL_TARGETS,
...SLAB_TARGETS,
...ROOF_TARGETS,
...STAIR_AND_FENCE_TARGETS,
...CEILING_TARGETS,
],
previewColor: '#ffffff',
preset: {
maps: {},
mapProperties: {
color: '#ffffff',
roughness: 0.9,
metalness: 0,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: false,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'preset-metal',
label: 'Metal',
description: 'Brushed metal finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#c0c0c0',
preset: {
maps: {},
mapProperties: {
color: '#c7ccd2',
roughness: 0.26,
metalness: 0.82,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: false,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 0,
opacity: 1,
lightMapIntensity: 1,
},
},
},
{
id: 'preset-glass',
label: 'Glass',
description: 'Light glass finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#87ceeb',
preset: {
maps: {},
mapProperties: {
color: '#87ceeb',
roughness: 0.1,
metalness: 0.1,
repeatX: 1,
repeatY: 1,
rotation: 0,
wrapS: 'Repeat',
wrapT: 'Repeat',
normalScaleX: 1,
normalScaleY: 1,
emissiveIntensity: 1,
displacementScale: 0.02,
transparent: true,
flipY: true,
bumpScale: 1,
emissiveColor: '#000000',
aoMapIntensity: 1,
side: 2,
opacity: 0.3,
lightMapIntensity: 1,
},
},
},
]
export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] {
return MATERIAL_CATALOG.filter((item) => item.targets.includes(target))
}
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
if (!id) return undefined
return MATERIAL_CATALOG.find((item) => item.id === id)
}
export const LIBRARY_MATERIAL_REF_PREFIX = 'library:'
export function toLibraryMaterialRef(id: string) {
return `${LIBRARY_MATERIAL_REF_PREFIX}${id}`
}
export function getLibraryMaterialIdFromRef(materialRef?: string | null) {
if (!materialRef) return null
if (!materialRef.startsWith(LIBRARY_MATERIAL_REF_PREFIX)) return null
return materialRef.slice(LIBRARY_MATERIAL_REF_PREFIX.length)
}
export function getMaterialPresetByRef(materialRef?: string | null): MaterialPresetPayload | null {
const materialId = getLibraryMaterialIdFromRef(materialRef)
if (!materialId) return null
return getCatalogMaterialById(materialId)?.preset ?? null
}
+12
View File
@@ -7,11 +7,23 @@ export { type Collection, type CollectionId, generateCollectionId } from './coll
// Material // Material
export { export {
DEFAULT_MATERIALS, DEFAULT_MATERIALS,
MaterialMapPropertiesSchema,
MaterialMapsSchema,
MaterialPreset, MaterialPreset,
MaterialPresetPayloadSchema,
MaterialProperties, MaterialProperties,
MaterialSchema, MaterialSchema,
MaterialTarget,
TextureWrapMode,
resolveMaterial, resolveMaterial,
} from './material' } from './material'
export type {
MaterialMapProperties,
MaterialMaps,
MaterialPresetPayload,
MaterialTarget as MaterialTargetValue,
TextureWrapMode as TextureWrapModeValue,
} from './material'
export { BuildingNode } from './nodes/building' export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling' export { CeilingNode } from './nodes/ceiling'
export { DoorNode, DoorSegment } from './nodes/door' export { DoorNode, DoorSegment } from './nodes/door'
+62
View File
@@ -25,6 +25,7 @@ export const MaterialProperties = z.object({
export type MaterialProperties = z.infer<typeof MaterialProperties> export type MaterialProperties = z.infer<typeof MaterialProperties>
export const MaterialSchema = z.object({ export const MaterialSchema = z.object({
id: z.string().optional(),
preset: MaterialPreset.optional(), preset: MaterialPreset.optional(),
properties: MaterialProperties.optional(), properties: MaterialProperties.optional(),
texture: z texture: z
@@ -37,6 +38,67 @@ export const MaterialSchema = z.object({
}) })
export type MaterialSchema = z.infer<typeof MaterialSchema> export type MaterialSchema = z.infer<typeof MaterialSchema>
export const MaterialTarget = z.enum([
'wall',
'roof',
'roof-segment',
'stair',
'stair-segment',
'fence',
'slab',
'ceiling',
'door',
'window',
])
export type MaterialTarget = z.infer<typeof MaterialTarget>
export const TextureWrapMode = z.enum(['Repeat', 'ClampToEdge', 'MirroredRepeat'])
export type TextureWrapMode = z.infer<typeof TextureWrapMode>
export const MaterialMapsSchema = z.object({
albedoMap: z.string().optional(),
metalnessMap: z.string().optional(),
roughnessMap: z.string().optional(),
normalMap: z.string().optional(),
displacementMap: z.string().optional(),
aoMap: z.string().optional(),
emissiveMap: z.string().optional(),
bumpMap: z.string().optional(),
alphaMap: z.string().optional(),
lightMap: z.string().optional(),
})
export type MaterialMaps = z.infer<typeof MaterialMapsSchema>
export const MaterialMapPropertiesSchema = z.object({
color: z.string().default('#ffffff'),
roughness: z.number().min(0).max(1).default(0.5),
metalness: z.number().min(0).max(1).default(0),
repeatX: z.number().default(1),
repeatY: z.number().default(1),
rotation: z.number().default(0),
wrapS: TextureWrapMode.default('Repeat'),
wrapT: TextureWrapMode.default('Repeat'),
normalScaleX: z.number().default(1),
normalScaleY: z.number().default(1),
emissiveIntensity: z.number().default(1),
displacementScale: z.number().default(0.02),
transparent: z.boolean().default(false),
flipY: z.boolean().default(true),
bumpScale: z.number().default(1),
emissiveColor: z.string().default('#000000'),
aoMapIntensity: z.number().default(1),
side: z.number().default(0),
opacity: z.number().min(0).max(1).default(1),
lightMapIntensity: z.number().default(1),
})
export type MaterialMapProperties = z.infer<typeof MaterialMapPropertiesSchema>
export const MaterialPresetPayloadSchema = z.object({
maps: MaterialMapsSchema,
mapProperties: MaterialMapPropertiesSchema,
})
export type MaterialPresetPayload = z.infer<typeof MaterialPresetPayloadSchema>
export const DEFAULT_MATERIALS: Record<MaterialPreset, MaterialProperties> = { export const DEFAULT_MATERIALS: Record<MaterialPreset, MaterialProperties> = {
white: { white: {
color: '#ffffff', color: '#ffffff',
@@ -9,6 +9,7 @@ export const CeilingNode = BaseNode.extend({
type: nodeType('ceiling'), type: nodeType('ceiling'),
children: z.array(ItemNode.shape.id).default([]), children: z.array(ItemNode.shape.id).default([]),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
height: z.number().default(2.5), // Height in meters height: z.number().default(2.5), // Height in meters
+3
View File
@@ -1,6 +1,7 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
export const FenceStyle = z.enum(['slat', 'rail', 'privacy']) export const FenceStyle = z.enum(['slat', 'rail', 'privacy'])
export const FenceBaseStyle = z.enum(['floating', 'grounded']) export const FenceBaseStyle = z.enum(['floating', 'grounded'])
@@ -8,6 +9,8 @@ export const FenceBaseStyle = z.enum(['floating', 'grounded'])
export const FenceNode = BaseNode.extend({ export const FenceNode = BaseNode.extend({
id: objectId('fence'), id: objectId('fence'),
type: nodeType('fence'), type: nodeType('fence'),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
start: z.tuple([z.number(), z.number()]), start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]),
height: z.number().default(1.8), height: z.number().default(1.8),
@@ -11,6 +11,7 @@ export const RoofSegmentNode = BaseNode.extend({
id: objectId('rseg'), id: objectId('rseg'),
type: nodeType('roof-segment'), type: nodeType('roof-segment'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
+1
View File
@@ -8,6 +8,7 @@ export const RoofNode = BaseNode.extend({
id: objectId('roof'), id: objectId('roof'),
type: nodeType('roof'), type: nodeType('roof'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
+1
View File
@@ -7,6 +7,7 @@ export const SlabNode = BaseNode.extend({
id: objectId('slab'), id: objectId('slab'),
type: nodeType('slab'), type: nodeType('slab'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
elevation: z.number().default(0.05), // Elevation in meters elevation: z.number().default(0.05), // Elevation in meters
@@ -15,6 +15,7 @@ export const StairSegmentNode = BaseNode.extend({
id: objectId('sseg'), id: objectId('sseg'),
type: nodeType('stair-segment'), type: nodeType('stair-segment'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
+1
View File
@@ -16,6 +16,7 @@ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
type: nodeType('stair'), type: nodeType('stair'),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
+3
View File
@@ -13,8 +13,10 @@ export const WallNode = BaseNode.extend({
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
.default([]), .default([]),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
thickness: z.number().optional(), thickness: z.number().optional(),
height: z.number().optional(), height: z.number().optional(),
curveOffset: z.number().optional(),
// e.g., start/end points for path // e.g., start/end points for path
start: z.tuple([z.number(), z.number()]), start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]),
@@ -26,6 +28,7 @@ export const WallNode = BaseNode.extend({
Wall node - used to represent a wall in the building Wall node - used to represent a wall in the building
- thickness: thickness in meters - thickness: thickness in meters
- height: height 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 - start: start point of the wall in level coordinate system
- end: end 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 - size: size of the wall in grid units
@@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, CeilingNode } from '../../schema' import type { AnyNodeId, CeilingNode } from '../../schema'
import useScene from '../../store/use-scene' 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 // CEILING SYSTEM
// ============================================================================ // ============================================================================
@@ -100,6 +107,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
// Rotate so the shape lies flat in X-Z plane // Rotate so the shape lies flat in X-Z plane
geometry.rotateX(-Math.PI / 2) geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals() geometry.computeVertexNormals()
ensureUv2Attribute(geometry)
return geometry return geometry
} }
@@ -10,6 +10,66 @@ type FencePart = {
scale: [number, number, number] scale: [number, number, number]
} }
function applyFenceUVs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
if (!(position && normal)) return
const uvs = new Float32Array(position.count * 2)
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index)
const py = position.getY(index)
const pz = position.getZ(index)
minX = Math.min(minX, px)
minY = Math.min(minY, py)
minZ = Math.min(minZ, pz)
maxX = Math.max(maxX, px)
maxY = Math.max(maxY, py)
maxZ = Math.max(maxZ, pz)
}
const width = Math.max(maxX - minX, 0.001)
const height = Math.max(maxY - minY, 0.001)
const depth = Math.max(maxZ - minZ, 0.001)
for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index)
const py = position.getY(index)
const pz = position.getZ(index)
const nx = Math.abs(normal.getX(index))
const ny = Math.abs(normal.getY(index))
const nz = Math.abs(normal.getZ(index))
let u = 0
let v = 0
if (ny >= nx && ny >= nz) {
u = (px - minX) / width
v = (pz - minZ) / depth
} else if (nx >= nz) {
u = (pz - minZ) / depth
v = (py - minY) / height
} else {
u = (px - minX) / width
v = (py - minY) / height
}
uvs[index * 2] = u
uvs[index * 2 + 1] = v
}
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
}
function getStyleDefaults(style: FenceNode['style']) { function getStyleDefaults(style: FenceNode['style']) {
if (style === 'privacy') { if (style === 'privacy') {
return { spacingFactor: 0.42, postFactor: 1.35, baseFactor: 1.2, topFactor: 1.2 } return { spacingFactor: 0.42, postFactor: 1.35, baseFactor: 1.2, topFactor: 1.2 }
@@ -103,6 +163,7 @@ function generateFenceGeometry(fence: FenceNode) {
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose()) geometries.forEach((geometry) => geometry.dispose())
applyFenceUVs(merged)
merged.computeVertexNormals() merged.computeVertexNormals()
return merged return merged
} }
+46 -6
View File
@@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, SlabNode } from '../../schema' import type { AnyNodeId, SlabNode } from '../../schema'
import useScene from '../../store/use-scene' 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 // SLAB SYSTEM
// ============================================================================ // ============================================================================
@@ -39,6 +46,7 @@ export const SlabSystem = () => {
*/ */
function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
const newGeo = generateSlabGeometry(node) const newGeo = generateSlabGeometry(node)
ensureUv2Attribute(newGeo)
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = newGeo mesh.geometry = newGeo
@@ -157,16 +165,46 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
if (polygon.length < 3) return new THREE.BufferGeometry() if (polygon.length < 3) return new THREE.BufferGeometry()
const positions: number[] = [] const positions: number[] = []
const uvs: number[] = []
const indices: number[] = [] const indices: number[] = []
const n = polygon.length 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 --- // --- 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 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!))) const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of slabNode.holes ?? []) { 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) const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
@@ -182,11 +220,12 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const [x0, z0] = polygon[i]! const [x0, z0] = polygon[i]!
const [x1, z1] = polygon[j]! const [x1, z1] = polygon[j]!
const vBase = positions.length / 3 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 pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level
positions.push(x1!, 0, z1!) // v1 — floor level pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level
positions.push(x1!, depth, z1!) // v2 — ground level pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level
positions.push(x0!, depth, z0!) // v3 — ground level pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level
indices.push(vBase, vBase + 1, vBase + 2) indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3) indices.push(vBase, vBase + 2, vBase + 3)
@@ -194,6 +233,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const geo = new THREE.BufferGeometry() const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geo.setIndex(indices) geo.setIndex(indices)
geo.computeVertexNormals() geo.computeVertexNormals()
return geo return geo
@@ -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<WallNode, 'start' | 'end' | 'curveOffset'>
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<WallNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
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()]
}
@@ -1,8 +1,15 @@
import type { WallNode } from '../../schema' 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_THICKNESS = 0.1
export const DEFAULT_WALL_HEIGHT = 2.5 export const DEFAULT_WALL_HEIGHT = 2.5
const CURVED_WALL_SURFACE_SEGMENTS = 24
export function getWallThickness(wallNode: WallNode): number { export function getWallThickness(wallNode: WallNode): number {
return wallNode.thickness ?? DEFAULT_WALL_THICKNESS return wallNode.thickness ?? DEFAULT_WALL_THICKNESS
@@ -10,25 +17,38 @@ export function getWallThickness(wallNode: WallNode): number {
export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] { export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] {
const { junctionData } = miterData const { junctionData } = miterData
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
const thickness = getWallThickness(wallNode) const thickness = getWallThickness(wallNode)
const halfT = thickness / 2 const halfT = thickness / 2
const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y } const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }
const L = Math.sqrt(v.x * v.x + v.y * v.y) const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) { if (L < 1e-9) {
return [] return []
} }
const nUnit = { x: -v.y / L, y: v.x / L } const nUnit = { x: -v.y / L, y: v.x / L }
const keyStart = pointToKey(wallStart) const keyStart = pointToKey(wallStart)
const keyEnd = pointToKey(wallEnd) const keyEnd = pointToKey(wallEnd)
const startJunction = junctionData.get(keyStart)?.get(wallNode.id) const startJunction = junctionData.get(keyStart)?.get(wallNode.id)
const endJunction = junctionData.get(keyEnd)?.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 || { const pStartLeft: Point2D = startJunction?.left || {
x: wallStart.x + nUnit.x * halfT, x: wallStart.x + nUnit.x * halfT,
y: wallStart.y + nUnit.y * halfT, y: wallStart.y + nUnit.y * halfT,
@@ -37,8 +57,6 @@ export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterDat
x: wallStart.x - nUnit.x * halfT, x: wallStart.x - nUnit.x * halfT,
y: wallStart.y - nUnit.y * halfT, y: wallStart.y - nUnit.y * halfT,
} }
// Junction offsets are stored relative to the outgoing direction.
const pEndLeft: Point2D = endJunction?.right || { const pEndLeft: Point2D = endJunction?.right || {
x: wallEnd.x + nUnit.x * halfT, x: wallEnd.x + nUnit.x * halfT,
y: wallEnd.y + nUnit.y * halfT, y: wallEnd.y + nUnit.y * halfT,
+104 -4
View File
@@ -1,4 +1,5 @@
import type { WallNode } from '../../schema' import type { WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from './wall-curve'
// ============================================================================ // ============================================================================
// TYPES // TYPES
@@ -9,6 +10,13 @@ export interface Point2D {
y: number y: number
} }
export interface WallMiterBoundaryPoints {
startLeft: Point2D
startRight: Point2D
endLeft: Point2D
endRight: Point2D
}
interface LineEquation { interface LineEquation {
a: number a: number
b: number b: number
@@ -127,6 +135,70 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
return actualJunctions 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) // MITER CALCULATION (exactly like demo)
// ============================================================================ // ============================================================================
@@ -171,10 +243,7 @@ function calculateJunctionIntersections(
} }
} else { } else {
// Normal wall endpoint (start or end) // Normal wall endpoint (start or end)
const v = const v = getWallDirectionFromJunction(wall, endType)
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 L = Math.sqrt(v.x * v.x + v.y * v.y) const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) continue if (L < 1e-9) continue
@@ -264,6 +333,37 @@ export function calculateLevelMiters(walls: WallNode[]): WallMiterData {
return { junctionData, junctions } 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 * Gets wall IDs that share junctions with the given walls
*/ */
+70 -1
View File
@@ -8,15 +8,75 @@ import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import { import {
calculateLevelMiters, calculateLevelMiters,
getAdjacentWallIds, getAdjacentWallIds,
getWallMiterBoundaryPoints,
type Point2D, type Point2D,
type WallMiterData, type WallMiterData,
pointToKey,
} from './wall-mitering' } from './wall-mitering'
// Reusable CSG evaluator for better performance // Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
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<typeof getWallMiterBoundaryPoints>,
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 // WALL SYSTEM
@@ -163,7 +223,14 @@ export function generateExtrudedWall(
if (L < 1e-9) { if (L < 1e-9) {
return new THREE.BufferGeometry() 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) { if (polyPoints.length < 3) {
return new THREE.BufferGeometry() return new THREE.BufferGeometry()
} }
@@ -205,6 +272,7 @@ export function generateExtrudedWall(
// Rotate so extrusion direction (Z) becomes height direction (Y) // Rotate so extrusion direction (Z) becomes height direction (Y)
geometry.rotateX(-Math.PI / 2) geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals() geometry.computeVertexNormals()
ensureUv2Attribute(geometry)
// Apply CSG subtraction for cutouts (doors/windows) // Apply CSG subtraction for cutouts (doors/windows)
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)
@@ -239,6 +307,7 @@ export function generateExtrudedWall(
const resultGeometry = resultBrush.geometry const resultGeometry = resultBrush.geometry
resultGeometry.computeVertexNormals() resultGeometry.computeVertexNormals()
ensureUv2Attribute(resultGeometry)
return resultGeometry return resultGeometry
} }
@@ -38,7 +38,7 @@ const ALLOWED_TYPES = [
'slab', 'slab',
'ceiling', 'ceiling',
] ]
const DELETE_ONLY_TYPES = ['wall'] const DELETE_ONLY_TYPES: string[] = []
const HOLE_TYPES = ['slab', 'ceiling'] const HOLE_TYPES = ['slab', 'ceiling']
export function FloatingActionMenu() { export function FloatingActionMenu() {
@@ -48,6 +48,7 @@ export function FloatingActionMenu() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
@@ -57,6 +58,18 @@ export function FloatingActionMenu() {
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const node = selectedId ? nodes[selectedId as AnyNodeId] : null const node = selectedId ? nodes[selectedId as AnyNodeId] : null
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false 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(() => { useFrame(() => {
if (!(selectedId && isValidType && groupRef.current)) return if (!(selectedId && isValidType && groupRef.current)) return
@@ -84,7 +97,10 @@ export function FloatingActionMenu() {
node.type === 'item' || node.type === 'item' ||
node.type === 'window' || node.type === 'window' ||
node.type === 'door' || node.type === 'door' ||
node.type === 'wall' ||
node.type === 'fence' || node.type === 'fence' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' || node.type === 'roof' ||
node.type === 'roof-segment' || node.type === 'roof-segment' ||
node.type === 'stair' || node.type === 'stair' ||
@@ -96,6 +112,16 @@ export function FloatingActionMenu() {
}, },
[node, setMovingNode, setSelection], [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( const handleDuplicate = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -263,10 +289,15 @@ export function FloatingActionMenu() {
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (!selectedId) return if (!selectedId) return
if (node?.type === 'item') {
sfxEmitter.emit('sfx:item-delete')
} else {
sfxEmitter.emit('sfx:structure-delete')
}
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
useScene.getState().deleteNode(selectedId as AnyNodeId) useScene.getState().deleteNode(selectedId as AnyNodeId)
}, },
[selectedId, setSelection], [node?.type, selectedId, setSelection],
) )
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
@@ -283,17 +314,14 @@ export function FloatingActionMenu() {
> >
<NodeActionMenu <NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined} onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={canCurveSelectedWall ? handleCurve : undefined}
onDelete={handleDelete} onDelete={handleDelete}
onDuplicate={ onDuplicate={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type) node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
? handleDuplicate ? handleDuplicate
: undefined : undefined
} }
onMove={ onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
? handleMove
: undefined
}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()}
/> />
@@ -6,11 +6,17 @@ import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
calculateLevelMiters, calculateLevelMiters,
type CeilingNode,
DoorNode, DoorNode,
emitter, emitter,
type GridEvent, type GridEvent,
type GuideNode, type GuideNode,
getWallChordFrame,
isCurvedWall,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
getScaledDimensions, getScaledDimensions,
getWallCurveLength,
getWallPlanFootprint, getWallPlanFootprint,
type ItemNode, type ItemNode,
ItemNode as ItemNodeSchema, ItemNode as ItemNodeSchema,
@@ -235,6 +241,12 @@ type WallEndpointDragState = {
currentPoint: WallPlanPoint currentPoint: WallPlanPoint
} }
type WallCurveDragState = {
pointerId: number
wallId: WallNode['id']
currentCurveOffset: number
}
const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
type GuideCorner = (typeof GUIDE_CORNERS)[number] type GuideCorner = (typeof GUIDE_CORNERS)[number]
@@ -276,6 +288,11 @@ type WallEndpointDraft = {
end: WallPlanPoint end: WallPlanPoint
} }
type WallCurveDraft = {
wallId: WallNode['id']
curveOffset: number
}
type SlabBoundaryDraft = { type SlabBoundaryDraft = {
slabId: SlabNode['id'] slabId: SlabNode['id']
polygon: WallPlanPoint[] polygon: WallPlanPoint[]
@@ -2176,7 +2193,7 @@ function getWallMeasurementOverlay(
): WallMeasurementOverlay | null { ): WallMeasurementOverlay | null {
const dx = wall.end[0] - wall.start[0] const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1] const dz = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dz) const length = getWallCurveLength(wall)
if (length < 0.1) { if (length < 0.1) {
return null return null
@@ -2425,13 +2442,19 @@ function buildGridPath(
function findClosestWallPoint( function findClosestWallPoint(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
maxDistance = 0.5, options?: {
maxDistance?: number
canUseWall?: (wall: WallNode) => boolean
},
): { ): {
wall: WallNode wall: WallNode
point: WallPlanPoint point: WallPlanPoint
t: number t: number
normal: [number, number, number] normal: [number, number, number]
} | null { } | null {
const maxDistance = options?.maxDistance ?? 0.5
const canUseWall = options?.canUseWall
let best: { let best: {
wall: WallNode wall: WallNode
point: WallPlanPoint point: WallPlanPoint
@@ -2441,6 +2464,10 @@ function findClosestWallPoint(
let bestDistSq = maxDistance * maxDistance let bestDistSq = maxDistance * maxDistance
for (const wall of walls) { for (const wall of walls) {
if (canUseWall && !canUseWall(wall)) {
continue
}
const [x1, z1] = wall.start const [x1, z1] = wall.start
const [x2, z2] = wall.end const [x2, z2] = wall.end
const dx = x2 - x1 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<SVGCircleElement>) => 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 (
<g
key={handleId}
onClick={(event) => {
event.stopPropagation()
}}
onPointerEnter={() => onHandleHoverChange(handleId)}
onPointerLeave={() => onHandleHoverChange(null)}
>
<circle
cx={svgPoint.x}
cy={svgPoint.y}
fill="none"
pointerEvents="none"
r={radius}
stroke={hoverStroke}
strokeOpacity={isActive ? 0.24 : 0.16}
strokeWidth={FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH}
style={{
opacity: isHovered ? 1 : 0,
transition: FLOORPLAN_HOVER_TRANSITION,
}}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={svgPoint.x}
cy={svgPoint.y}
fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}
fillOpacity={0.96}
pointerEvents="none"
r={radius}
stroke={stroke}
strokeWidth="0.05"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={svgPoint.x}
cy={svgPoint.y}
fill={stroke}
pointerEvents="none"
r={0.045}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={svgPoint.x}
cy={svgPoint.y}
fill="transparent"
onPointerDown={(event) => onWallCurvePointerDown(wall, event)}
pointerEvents="all"
r={radius}
stroke="transparent"
strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH}
style={{ cursor: EDITOR_CURSOR }}
vectorEffect="non-scaling-stroke"
/>
</g>
)
})}
</>
)
})
const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
hoveredHandleId, hoveredHandleId,
midpointHandles, midpointHandles,
@@ -4544,6 +4662,7 @@ export function FloorplanPanel() {
const guideInteractionRef = useRef<GuideInteractionState | null>(null) const guideInteractionRef = useRef<GuideInteractionState | null>(null)
const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null) const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null) const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
const wallCurveDragRef = useRef<WallCurveDragState | null>(null)
const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null) const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null) const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null)
const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null) const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null)
@@ -4576,6 +4695,7 @@ export function FloorplanPanel() {
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const setMode = useEditor((state) => state.setMode) const setMode = useEditor((state) => state.setMode)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
@@ -4680,6 +4800,22 @@ export function FloorplanPanel() {
.filter((node): node is SlabNode => node?.type === 'slab') .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( const levelGuides = useScene(
useShallow((state) => { useShallow((state) => {
if (!levelId) { if (!levelId) {
@@ -4741,6 +4877,7 @@ export function FloorplanPanel() {
const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null) const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null)
const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null) const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null)
const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null) const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null) const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null) const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null) const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
@@ -4748,6 +4885,7 @@ export function FloorplanPanel() {
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null) const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null) const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null) const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null) const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null)
const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null) const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null) const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
@@ -4922,29 +5060,42 @@ export function FloorplanPanel() {
[floorplanWalls], [floorplanWalls],
) )
const displayWallById = useMemo(() => { const displayWallById = useMemo(() => {
if (!wallEndpointDraft) { if (!wallEndpointDraft && !wallCurveDraft) {
return wallById
}
const wall = wallById.get(wallEndpointDraft.wallId)
if (!wall) {
return wallById return wallById
} }
const nextWallById = new Map(wallById) const nextWallById = new Map(wallById)
nextWallById.set(
wall.id, if (wallEndpointDraft) {
buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end), 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 return nextWallById
}, [wallById, wallEndpointDraft]) }, [wallById, wallCurveDraft, wallEndpointDraft])
const displayFloorplanWallById = useMemo(() => { const displayFloorplanWallById = useMemo(() => {
if (!wallEndpointDraft) { if (!wallEndpointDraft && !wallCurveDraft) {
return floorplanWallById 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) { if (!previewWall) {
return floorplanWallById return floorplanWallById
} }
@@ -4952,7 +5103,7 @@ export function FloorplanPanel() {
const nextFloorplanWallById = new Map(floorplanWallById) const nextFloorplanWallById = new Map(floorplanWallById)
nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall)) nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall))
return nextFloorplanWallById return nextFloorplanWallById
}, [displayWallById, floorplanWallById, wallEndpointDraft]) }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
const wallPolygons = useMemo( const wallPolygons = useMemo(
() => () =>
walls.map((wall) => { walls.map((wall) => {
@@ -4967,11 +5118,16 @@ export function FloorplanPanel() {
[floorplanWallById, wallMiterData, walls], [floorplanWallById, wallMiterData, walls],
) )
const displayWallPolygons = useMemo(() => { const displayWallPolygons = useMemo(() => {
if (!wallEndpointDraft) { if (!wallEndpointDraft && !wallCurveDraft) {
return wallPolygons 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) { if (!previewWall) {
return wallPolygons return wallPolygons
} }
@@ -4990,7 +5146,7 @@ export function FloorplanPanel() {
} }
: entry, : entry,
) )
}, [displayWallById, wallEndpointDraft, wallPolygons]) }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
const openingsPolygons = useMemo( const openingsPolygons = useMemo(
() => () =>
@@ -5046,6 +5202,29 @@ export function FloorplanPanel() {
: entry, : entry,
) )
}, [slabBoundaryDraft, slabPolygons]) }, [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( const zonePolygons = useMemo(
() => () =>
zones.flatMap((zone) => { zones.flatMap((zone) => {
@@ -5176,6 +5355,13 @@ export function FloorplanPanel() {
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
}, [floorplanItemEntries, selectedIds]) }, [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(() => { const selectedStairEntry = useMemo(() => {
if (selectedIds.length !== 1) { if (selectedIds.length !== 1) {
return null return null
@@ -5192,6 +5378,13 @@ export function FloorplanPanel() {
return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null
}, [displaySlabPolygons, selectedIds]) }, [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(() => { const selectedZoneEntry = useMemo(() => {
if (!selectedZoneId) { if (!selectedZoneId) {
return null return null
@@ -5212,12 +5405,23 @@ export function FloorplanPanel() {
const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair' const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
const isStairMoveActive = movingNode?.type === '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 = const isItemPlacementPreviewActive =
(mode === 'build' && tool === 'item') || movingNode?.type === 'item' (mode === 'build' && tool === 'item') || movingNode?.type === 'item'
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo
const isFloorplanGridInteractionActive = const isFloorplanGridInteractionActive =
isStairBuildActive || isStairMoveActive || isFloorItemBuildActive || isFloorItemMoveActive isStairBuildActive ||
isStairMoveActive ||
isSlabMoveActive ||
isCeilingMoveActive ||
isWallMoveActive ||
isWallCurveActive ||
isFloorItemBuildActive ||
isFloorItemMoveActive
const floorplanPreviewStairSegment = useMemo( const floorplanPreviewStairSegment = useMemo(
() => () =>
StairSegmentNodeSchema.parse({ StairSegmentNodeSchema.parse({
@@ -5399,6 +5603,56 @@ export function FloorplanPanel() {
shouldShowPersistentWallEndpointHandles, shouldShowPersistentWallEndpointHandles,
wallEndpointDraft, 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(() => { const slabVertexHandles = useMemo(() => {
if (!shouldShowSlabBoundaryHandles) { if (!shouldShowSlabBoundaryHandles) {
return [] return []
@@ -5717,6 +5971,27 @@ export function FloorplanPanel() {
: null, : null,
[selectedItemEntry, surfaceSize, viewBox], [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( const selectedStairActionMenuPosition = useMemo(
() => () =>
selectedStairEntry selectedStairEntry
@@ -6198,6 +6473,11 @@ export function FloorplanPanel() {
setWallEndpointDraft(null) setWallEndpointDraft(null)
setHoveredEndpointId(null) setHoveredEndpointId(null)
}, []) }, [])
const clearWallCurveDrag = useCallback(() => {
wallCurveDragRef.current = null
setWallCurveDraft(null)
setHoveredWallCurveHandleId(null)
}, [])
const clearSiteBoundaryInteraction = useCallback(() => { const clearSiteBoundaryInteraction = useCallback(() => {
setSiteVertexDragState(null) setSiteVertexDragState(null)
setSiteBoundaryDraft(null) setSiteBoundaryDraft(null)
@@ -6219,11 +6499,13 @@ export function FloorplanPanel() {
clearSlabPlacementDraft() clearSlabPlacementDraft()
clearZonePlacementDraft() clearZonePlacementDraft()
clearWallEndpointDrag() clearWallEndpointDrag()
clearWallCurveDrag()
clearSiteBoundaryInteraction() clearSiteBoundaryInteraction()
clearSlabBoundaryInteraction() clearSlabBoundaryInteraction()
clearZoneBoundaryInteraction() clearZoneBoundaryInteraction()
setCursorPoint(null) setCursorPoint(null)
}, [ }, [
clearWallCurveDrag,
clearSiteBoundaryInteraction, clearSiteBoundaryInteraction,
clearSlabBoundaryInteraction, clearSlabBoundaryInteraction,
clearSlabPlacementDraft, clearSlabPlacementDraft,
@@ -6430,51 +6712,86 @@ export function FloorplanPanel() {
} }
const dragState = wallEndpointDragRef.current 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 return
} }
event.preventDefault() event.preventDefault()
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
if (!planPoint) { const wall = wallById.get(curveDragState.wallId)
if (!(planPoint && wall)) {
return return
} }
const snappedPoint = snapWallDraftPoint({ const chord = getWallChordFrame(wall)
point: planPoint, const snappedPoint: WallPlanPoint = shiftPressed
walls, ? planPoint
start: dragState.fixedPoint, : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
angleSnap: !shiftPressed, const rawCurveOffset =
ignoreWallIds: [dragState.wallId], -(
}) (snappedPoint[0] - chord.midpoint.x) * chord.normal.x +
(snappedPoint[1] - chord.midpoint.y) * chord.normal.y
if (pointsEqual(dragState.currentPoint, snappedPoint)) {
return
}
dragState.currentPoint = snappedPoint
setCursorPoint(snappedPoint)
setWallEndpointDraft((previousDraft) => {
const nextDraft = buildWallEndpointDraft(
dragState.wallId,
dragState.endpoint,
dragState.fixedPoint,
snappedPoint,
) )
const nextCurveOffset = normalizeWallCurveOffset(
wall,
shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset),
)
if ( if (curveDragState.currentCurveOffset === nextCurveOffset) {
!( return
previousDraft && }
pointsEqual(previousDraft.start, nextDraft.start) &&
pointsEqual(previousDraft.end, nextDraft.end)
)
) {
sfxEmitter.emit('sfx:grid-snap')
}
return nextDraft curveDragState.currentCurveOffset = nextCurveOffset
}) setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset })
setCursorPoint(snappedPoint)
sfxEmitter.emit('sfx:grid-snap')
} }
const commitGuideInteraction = (event: PointerEvent) => { const commitGuideInteraction = (event: PointerEvent) => {
@@ -6559,6 +6876,26 @@ export function FloorplanPanel() {
setCursorPoint(null) 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 cancelWallEndpointDrag = (event: PointerEvent) => {
const dragState = wallEndpointDragRef.current const dragState = wallEndpointDragRef.current
if (!dragState || event.pointerId !== dragState.pointerId) { if (!dragState || event.pointerId !== dragState.pointerId) {
@@ -6569,11 +6906,23 @@ export function FloorplanPanel() {
setCursorPoint(null) 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('pointermove', handleWindowPointerMove)
window.addEventListener('pointerup', commitGuideInteraction) window.addEventListener('pointerup', commitGuideInteraction)
window.addEventListener('pointercancel', cancelGuideInteraction) window.addEventListener('pointercancel', cancelGuideInteraction)
window.addEventListener('pointerup', commitWallEndpointDrag) window.addEventListener('pointerup', commitWallEndpointDrag)
window.addEventListener('pointercancel', cancelWallEndpointDrag) window.addEventListener('pointercancel', cancelWallEndpointDrag)
window.addEventListener('pointerup', commitWallCurveDrag)
window.addEventListener('pointercancel', cancelWallCurveDrag)
return () => { return () => {
window.removeEventListener('pointermove', handleWindowPointerMove) window.removeEventListener('pointermove', handleWindowPointerMove)
@@ -6581,8 +6930,11 @@ export function FloorplanPanel() {
window.removeEventListener('pointercancel', cancelGuideInteraction) window.removeEventListener('pointercancel', cancelGuideInteraction)
window.removeEventListener('pointerup', commitWallEndpointDrag) window.removeEventListener('pointerup', commitWallEndpointDrag)
window.removeEventListener('pointercancel', cancelWallEndpointDrag) window.removeEventListener('pointercancel', cancelWallEndpointDrag)
window.removeEventListener('pointerup', commitWallCurveDrag)
window.removeEventListener('pointercancel', cancelWallCurveDrag)
} }
}, [ }, [
clearWallCurveDrag,
clearGuideInteraction, clearGuideInteraction,
clearWallEndpointDrag, clearWallEndpointDrag,
getSvgPointFromClientPoint, getSvgPointFromClientPoint,
@@ -6596,7 +6948,8 @@ export function FloorplanPanel() {
useEffect(() => { useEffect(() => {
clearWallEndpointDrag() clearWallEndpointDrag()
}, [clearWallEndpointDrag, levelId]) clearWallCurveDrag()
}, [clearWallCurveDrag, clearWallEndpointDrag, levelId])
useEffect(() => { useEffect(() => {
if (shouldShowSiteBoundaryHandles) { if (shouldShowSiteBoundaryHandles) {
@@ -7066,7 +7419,9 @@ export function FloorplanPanel() {
} }
if (isOpeningPlacementActive) { if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls) const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
if (closest) { if (closest) {
const dx = closest.wall.end[0] - closest.wall.start[0] const dx = closest.wall.end[0] - closest.wall.start[0]
const dz = closest.wall.end[1] - closest.wall.start[1] const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7286,7 +7641,9 @@ export function FloorplanPanel() {
} }
if (isOpeningPlacementActive) { if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls) const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
if (closest) { if (closest) {
const dx = closest.wall.end[0] - closest.wall.start[0] const dx = closest.wall.end[0] - closest.wall.start[0]
const dz = closest.wall.end[1] - closest.wall.start[1] const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7373,7 +7730,9 @@ export function FloorplanPanel() {
isOpeningPlacementActive, isOpeningPlacementActive,
isPolygonBuildActive, isPolygonBuildActive,
isWallBuildActive, isWallBuildActive,
isWindowBuildActive,
isZoneBuildActive, isZoneBuildActive,
movingOpeningType,
setSelectedReferenceId, setSelectedReferenceId,
setSelection, setSelection,
shiftPressed, shiftPressed,
@@ -8095,6 +8454,96 @@ export function FloorplanPanel() {
}, },
[deleteNode, selectedItemEntry, setSelection], [deleteNode, selectedItemEntry, setSelection],
) )
const handleSelectedWallMove = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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( const handleStairDoubleClick = useCallback(
(stair: StairNode, event: ReactMouseEvent<SVGElement>) => { (stair: StairNode, event: ReactMouseEvent<SVGElement>) => {
emitFloorplanNodeClick(stair.id, 'double-click', event) emitFloorplanNodeClick(stair.id, 'double-click', event)
@@ -8304,6 +8753,45 @@ export function FloorplanPanel() {
}, },
[clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode], [clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode],
) )
const handleWallCurvePointerDown = useCallback(
(wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => {
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( const handleSlabVertexPointerDown = useCallback(
(slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => { (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {
if (event.button !== 0) { if (event.button !== 0) {
@@ -8662,12 +9150,21 @@ export function FloorplanPanel() {
!zoneVertexDragState !zoneVertexDragState
) { ) {
const rect = event.currentTarget.getBoundingClientRect() const rect = event.currentTarget.getBoundingClientRect()
setFloorplanCursorPosition({ const nextPosition = {
x: event.clientX - rect.left, x: event.clientX - rect.left,
y: event.clientY - rect.top, y: event.clientY - rect.top,
}) }
setFloorplanCursorPosition((currentPosition) =>
currentPosition &&
currentPosition.x === nextPosition.x &&
currentPosition.y === nextPosition.y
? currentPosition
: nextPosition,
)
} else { } else {
setFloorplanCursorPosition(null) setFloorplanCursorPosition((currentPosition) =>
currentPosition === null ? currentPosition : null,
)
} }
handlePointerMove(event) handlePointerMove(event)
@@ -9230,7 +9727,7 @@ export function FloorplanPanel() {
rotationModifierPressed={rotationModifierPressed} rotationModifierPressed={rotationModifierPressed}
/> />
)} )}
{selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && ( {selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div <div
className="absolute z-30" className="absolute z-30"
style={{ style={{
@@ -9248,7 +9745,58 @@ export function FloorplanPanel() {
/> />
</div> </div>
)} )}
{selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && ( {selectedWallActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div
className="absolute z-30"
style={{
left: selectedWallActionMenuPosition.x,
top: selectedWallActionMenuPosition.y,
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
}}
>
<NodeActionMenu
onDelete={handleSelectedWallDelete}
onMove={handleSelectedWallMove}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>
)}
{selectedSlabActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div
className="absolute z-30"
style={{
left: selectedSlabActionMenuPosition.x,
top: selectedSlabActionMenuPosition.y,
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
}}
>
<NodeActionMenu
onDelete={handleSelectedSlabDelete}
onMove={handleSelectedSlabMove}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>
)}
{selectedCeilingActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div
className="absolute z-30"
style={{
left: selectedCeilingActionMenuPosition.x,
top: selectedCeilingActionMenuPosition.y,
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
}}
>
<NodeActionMenu
onDelete={handleSelectedCeilingDelete}
onMove={handleSelectedCeilingMove}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>
)}
{selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div <div
className="absolute z-30" className="absolute z-30"
style={{ style={{
@@ -9266,7 +9814,7 @@ export function FloorplanPanel() {
/> />
</div> </div>
)} )}
{selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && ( {selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div <div
className="absolute z-30" className="absolute z-30"
style={{ style={{
@@ -9543,6 +10091,13 @@ export function FloorplanPanel() {
onWallEndpointPointerDown={handleWallEndpointPointerDown} onWallEndpointPointerDown={handleWallEndpointPointerDown}
palette={palette} palette={palette}
/> />
<FloorplanWallCurveHandleLayer
curveHandles={wallCurveHandles}
hoveredHandleId={hoveredWallCurveHandleId}
onHandleHoverChange={setHoveredWallCurveHandleId}
onWallCurvePointerDown={handleWallCurvePointerDown}
palette={palette}
/>
<FloorplanPolygonHandleLayer <FloorplanPolygonHandleLayer
hoveredHandleId={hoveredSlabHandleId} hoveredHandleId={hoveredSlabHandleId}
@@ -1,7 +1,7 @@
'use client' 'use client'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { Copy, Move, Trash2 } from 'lucide-react' import { Copy, Move, Spline, Trash2 } from 'lucide-react'
import type { MouseEventHandler, PointerEventHandler } from 'react' import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = { type NodeActionMenuProps = {
@@ -9,6 +9,7 @@ type NodeActionMenuProps = {
onDelete?: MouseEventHandler<HTMLButtonElement> onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement> onDuplicate?: MouseEventHandler<HTMLButtonElement>
onMove?: MouseEventHandler<HTMLButtonElement> onMove?: MouseEventHandler<HTMLButtonElement>
onCurve?: MouseEventHandler<HTMLButtonElement>
onPointerDown?: PointerEventHandler<HTMLDivElement> onPointerDown?: PointerEventHandler<HTMLDivElement>
onPointerUp?: PointerEventHandler<HTMLDivElement> onPointerUp?: PointerEventHandler<HTMLDivElement>
onPointerEnter?: PointerEventHandler<HTMLDivElement> onPointerEnter?: PointerEventHandler<HTMLDivElement>
@@ -20,6 +21,7 @@ export function NodeActionMenu({
onDelete, onDelete,
onDuplicate, onDuplicate,
onMove, onMove,
onCurve,
onPointerDown, onPointerDown,
onPointerUp, onPointerUp,
onPointerEnter, onPointerEnter,
@@ -44,6 +46,17 @@ export function NodeActionMenu({
<Move className="h-4 w-4" /> <Move className="h-4 w-4" />
</button> </button>
)} )}
{onCurve && (
<button
aria-label="Curve"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onCurve}
title="Curve"
type="button"
>
<Spline className="h-4 w-4" />
</button>
)}
{onDuplicate && ( {onDuplicate && (
<button <button
aria-label="Duplicate" aria-label="Duplicate"
@@ -346,6 +346,7 @@ export const SelectionManager = () => {
const clickHandledRef = useRef(false) const clickHandledRef = useRef(false)
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const curvingWall = useEditor((s) => s.curvingWall)
useEffect(() => { useEffect(() => {
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default') setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
@@ -384,7 +385,7 @@ export const SelectionManager = () => {
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode) return if (movingNode || curvingWall) return
const onClick = (event: NodeEvent) => { const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node) // Skip if box-select just completed (drag ended over a node)
@@ -485,12 +486,12 @@ export const SelectionManager = () => {
}) })
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
} }
}, [mode, movingNode]) }, [curvingWall, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover // Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode) return if (movingNode || curvingWall) return
const onEnter = (event: NodeEvent) => { const onEnter = (event: NodeEvent) => {
const node = event.node const node = event.node
@@ -619,7 +620,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any) emitter.off(`${type}:double-click` as any, onDoubleClick as any)
}) })
} }
}, [mode, movingNode]) }, [curvingWall, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool) // Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => { useEffect(() => {
@@ -4,9 +4,14 @@ import {
type AnyNodeId, type AnyNodeId,
calculateLevelMiters, calculateLevelMiters,
DEFAULT_WALL_HEIGHT, DEFAULT_WALL_HEIGHT,
getWallCurveLength,
getWallMiterBoundaryPoints,
getWallPlanFootprint, getWallPlanFootprint,
getWallSurfacePolygon,
isCurvedWall,
type Point2D, type Point2D,
pointToKey, pointToKey,
sampleWallCenterline,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallMiterData, type WallMiterData,
@@ -15,7 +20,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber' import { createPortal, useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
const GUIDE_Y_OFFSET = 0.08 const GUIDE_Y_OFFSET = 0.08
@@ -28,8 +33,7 @@ const BAR_AXIS = new THREE.Vector3(0, 1, 0)
type Vec3 = [number, number, number] type Vec3 = [number, number, number]
type MeasurementGuide = { type MeasurementGuide = {
guideStart: Vec3 guidePath: Vec3[]
guideEnd: Vec3
extStartStart: Vec3 extStartStart: Vec3
extStartEnd: Vec3 extStartEnd: Vec3
extEndStart: Vec3 extEndStart: Vec3
@@ -56,18 +60,19 @@ export function WallMeasurementLabel() {
const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
const wall = selectedNode?.type === 'wall' ? selectedNode : null const wall = selectedNode?.type === 'wall' ? selectedNode : null
const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null) const [wallObjectState, setWallObjectState] = useState<{
id: WallNode['id']
useEffect(() => { object: THREE.Object3D
setWallObject(null) } | null>(null)
}, [selectedId]) const wallObject =
selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
useFrame(() => { useFrame(() => {
if (!selectedId || wallObject) return if (!selectedId || wallObject) return
const nextWallObject = sceneRegistry.nodes.get(selectedId) const nextWallObject = sceneRegistry.nodes.get(selectedId)
if (nextWallObject) { if (nextWallObject) {
setWallObject(nextWallObject) setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject })
} }
}) })
@@ -131,6 +136,34 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA] return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
} }
function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'>) {
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
return 1
}
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
return -1
}
return 1
}
function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null {
const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
if (!boundaryPoints) return null
const surface = getWallSurfacePolygon(wall, 24, boundaryPoints)
const sidePointCount = 25
if (surface.length < sidePointCount * 2) return null
const offsetSign = getWallExteriorOffsetSign(wall)
if (offsetSign >= 0) {
return surface.slice(sidePointCount).reverse()
}
return surface.slice(0, sidePointCount)
}
function buildMeasurementGuide( function buildMeasurementGuide(
wall: WallNode, wall: WallNode,
nodes: Record<string, WallNode | { type: string; children?: string[] }>, nodes: Record<string, WallNode | { type: string; children?: string[] }>,
@@ -143,31 +176,66 @@ function buildMeasurementGuide(
const height = wall.height ?? DEFAULT_WALL_HEIGHT const height = wall.height ?? DEFAULT_WALL_HEIGHT
const startLocal = worldPointToWallLocal(wall, middlePoints.start) const startLocal = worldPointToWallLocal(wall, middlePoints.start)
const endLocal = worldPointToWallLocal(wall, middlePoints.end) const endLocal = worldPointToWallLocal(wall, middlePoints.end)
const curvedMeasurementPath = isCurvedWall(wall)
? getCurvedWallMeasurementPath(wall, miterData)
: null
const guidePath: Vec3[] = curvedMeasurementPath
? curvedMeasurementPath.map((point) => {
const localPoint = worldPointToWallLocal(wall, point)
return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
})
: isCurvedWall(wall)
? sampleWallCenterline(wall, 24).map((point, index, points) => {
const localPoint =
index === 0
? startLocal
: index === points.length - 1
? endLocal
: worldPointToWallLocal(wall, point)
const guideStart: Vec3 = [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]] return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
const guideEnd: Vec3 = [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]] })
: [
[startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]],
[endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]],
]
const dirX = guideEnd[0] - guideStart[0] if (guidePath.length < 2) return null
const dirZ = guideEnd[2] - guideStart[2]
const dirLength = Math.hypot(dirX, dirZ)
if (!Number.isFinite(dirLength) || dirLength < 0.001) return null let guideLength = 0
for (let index = 1; index < guidePath.length; index += 1) {
const prev = guidePath[index - 1]!
const next = guidePath[index]!
guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2])
}
if (!Number.isFinite(guideLength) || guideLength < 0.001) return null
// Extension lines coming out of the extremity markers of the wall // Extension lines coming out of the extremity markers of the wall
const extOvershoot = 0.04 const extOvershoot = 0.04
const guideStart = guidePath[0]!
const guideEnd = guidePath[guidePath.length - 1]!
const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal
const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal
const midpoint = curvedMeasurementPath
? guidePath[Math.floor(guidePath.length / 2)]!
: ([
(guideStart[0] + guideEnd[0]) / 2,
guideStart[1],
(guideStart[2] + guideEnd[2]) / 2,
] as Vec3)
return { return {
guideStart, guidePath,
guideEnd, extStartStart: [extensionStartBase[0], height, extensionStartBase[2]],
extStartStart: [startLocal[0], height, startLocal[2]], extStartEnd: [
extStartEnd: [startLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, startLocal[2]], extensionStartBase[0],
extEndStart: [endLocal[0], height, endLocal[2]], height + GUIDE_Y_OFFSET + extOvershoot,
extEndEnd: [endLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, endLocal[2]], extensionStartBase[2],
labelPosition: [
(guideStart[0] + guideEnd[0]) / 2,
guideStart[1] + LABEL_LIFT,
(guideStart[2] + guideEnd[2]) / 2,
], ],
extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
} }
} }
@@ -208,6 +276,16 @@ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color:
) )
} }
function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
return (
<>
{path.slice(1).map((point, index) => (
<MeasurementBar color={color} end={point} key={index} start={path[index]!} />
))}
</>
)
}
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
const theme = useViewer((state) => state.theme) const theme = useViewer((state) => state.theme)
@@ -216,10 +294,6 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
const color = isNight ? '#ffffff' : '#111111' const color = isNight ? '#ffffff' : '#111111'
const shadowColor = isNight ? '#111111' : '#ffffff' const shadowColor = isNight ? '#111111' : '#ffffff'
const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dz)
const label = formatMeasurement(length, unit)
const guide = useMemo( const guide = useMemo(
() => () =>
buildMeasurementGuide( buildMeasurementGuide(
@@ -228,12 +302,26 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
), ),
[nodes, wall], [nodes, wall],
) )
const length = useMemo(() => {
if (!guide?.guidePath?.length || guide.guidePath.length < 2) {
return getWallCurveLength(wall)
}
let total = 0
for (let index = 1; index < guide.guidePath.length; index += 1) {
const prev = guide.guidePath[index - 1]!
const next = guide.guidePath[index]!
total += Math.hypot(next[0] - prev[0], next[2] - prev[2])
}
return total
}, [guide, wall])
const label = formatMeasurement(length, unit)
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
return ( return (
<group> <group>
<MeasurementBar color={color} end={guide.guideEnd} start={guide.guideStart} /> <MeasurementPath color={color} path={guide.guidePath} />
<MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} /> <MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
<MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} /> <MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
@@ -36,6 +36,7 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
return ( return (
<PolygonEditor <PolygonEditor
allowPolygonMove
color="#ef4444" color="#ef4444"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
minVertices={3} minVertices={3}
@@ -0,0 +1,154 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], node.height ?? 2.5, center[1]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
const center = getPolygonCenter(polygon)
setCursorLocalPos([center[0], node.height ?? 2.5, center[1]])
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.height, node.id])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -2,6 +2,7 @@ import {
type AnyNodeId, type AnyNodeId,
DoorNode, DoorNode,
emitter, emitter,
isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -84,6 +85,11 @@ export const DoorTool: React.FC = () => {
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
const levelId = getLevelId() const levelId = getLevelId()
if (!levelId) return if (!levelId) return
if (event.node.parentId !== levelId) return if (event.node.parentId !== levelId) return
@@ -130,6 +136,11 @@ export const DoorTool: React.FC = () => {
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -190,6 +201,7 @@ export const DoorTool: React.FC = () => {
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -2,6 +2,7 @@ import {
type AnyNodeId, type AnyNodeId,
DoorNode, DoorNode,
emitter, emitter,
isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -98,6 +99,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -151,6 +156,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -213,6 +222,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -1,21 +1,27 @@
import type { import type {
BuildingNode, BuildingNode,
CeilingNode,
DoorNode, DoorNode,
FenceNode, FenceNode,
ItemNode, ItemNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
SlabNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
WallNode,
WindowNode, WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool' import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
import { MoveDoorTool } from '../door/move-door-tool' import { MoveDoorTool } from '../door/move-door-tool'
import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveSlabTool } from '../slab/move-slab-tool'
import { MoveWallTool } from '../wall/move-wall-tool'
import { MoveWindowTool } from '../window/move-window-tool' import { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types' import type { PlacementState } from './placement-types'
import { useDraftNode } from './use-draft-node' import { useDraftNode } from './use-draft-node'
@@ -89,6 +95,9 @@ export const MoveTool: React.FC = () => {
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} /> if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} /> if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} /> if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} /> return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
@@ -9,8 +9,10 @@ const Y_OFFSET = 0.02
type DragState = { type DragState = {
isDragging: boolean isDragging: boolean
vertexIndex: number mode: 'vertex' | 'polygon'
vertexIndex: number | null
initialPosition: [number, number] initialPosition: [number, number]
initialPolygon: Array<[number, number]>
pointerId: number pointerId: number
} }
@@ -23,6 +25,8 @@ export interface PolygonEditorProps {
levelId?: string levelId?: string
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */ /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
surfaceHeight?: number surfaceHeight?: number
/** Whether to show the center handle that moves the entire polygon. */
allowPolygonMove?: boolean
} }
/** /**
@@ -38,6 +42,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
minVertices = 3, minVertices = 3,
levelId, levelId,
surfaceHeight = 0, surfaceHeight = 0,
allowPolygonMove = false,
}) => { }) => {
// Get level node from registry if levelId is provided // Get level node from registry if levelId is provided
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
@@ -75,6 +80,17 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// The polygon to display (preview during drag, or actual polygon) // The polygon to display (preview during drag, or actual polygon)
const displayPolygon = previewPolygon ?? polygon const displayPolygon = previewPolygon ?? polygon
const polygonCenter = useMemo(() => {
if (displayPolygon.length === 0) return [0, 0] as [number, number]
let sumX = 0
let sumZ = 0
for (const [x, z] of displayPolygon) {
sumX += x
sumZ += z
}
return [sumX / displayPolygon.length, sumZ / displayPolygon.length] as [number, number]
}, [displayPolygon])
// Calculate midpoints for adding new vertices // Calculate midpoints for adding new vertices
const midpoints = useMemo(() => { const midpoints = useMemo(() => {
if (displayPolygon.length < 2) return [] if (displayPolygon.length < 2) return []
@@ -158,7 +174,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Update vertex position during drag // Update vertex position during drag
if (dragState?.isDragging) { if (dragState?.isDragging) {
handleVertexDrag(dragState.vertexIndex, newPosition) if (dragState.mode === 'vertex' && dragState.vertexIndex !== null) {
handleVertexDrag(dragState.vertexIndex, newPosition)
} else if (dragState.mode === 'polygon') {
const deltaX = newPosition[0] - dragState.initialPosition[0]
const deltaZ = newPosition[1] - dragState.initialPosition[1]
setPreviewPolygon(
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
)
}
} }
} }
@@ -257,7 +281,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
{/* Vertex handles - blue cylinders that match surface height */} {/* Vertex handles - blue cylinders that match surface height */}
{displayPolygon.map(([x, z], index) => { {displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index const isHovered = hoveredVertex === index
const isDragging = dragState?.vertexIndex === index const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
const radius = 0.1 const radius = 0.1
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
@@ -282,8 +306,10 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
e.stopPropagation() e.stopPropagation()
setDragState({ setDragState({
isDragging: true, isDragging: true,
mode: 'vertex',
vertexIndex: index, vertexIndex: index,
initialPosition: [x!, z!], initialPosition: [x!, z!],
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId, pointerId: e.pointerId,
}) })
}} }}
@@ -305,6 +331,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
) )
})} })}
{allowPolygonMove && (
<mesh
castShadow
layers={EDITOR_LAYER}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
setDragState({
isDragging: true,
mode: 'polygon',
vertexIndex: null,
initialPosition: polygonCenter,
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId,
})
}}
position={[
polygonCenter[0],
editY + Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + 0.08,
polygonCenter[1],
]}
>
<sphereGeometry args={[0.09, 20, 20]} />
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
</mesh>
)}
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */} {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
{!dragState && {!dragState &&
midpoints.map(([x, z], index) => { midpoints.map(([x, z], index) => {
@@ -0,0 +1,154 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type SlabNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], 0, center[1]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
const center = getPolygonCenter(polygon)
setCursorLocalPos([center[0], 0, center[1]])
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.id])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -36,6 +36,7 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
return ( return (
<PolygonEditor <PolygonEditor
allowPolygonMove
color="#ef4444" color="#ef4444"
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
minVertices={3} minVertices={3}
@@ -20,6 +20,7 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool' import { SlabTool } from './slab/slab-tool'
import { StairTool } from './stair/stair-tool' import { StairTool } from './stair/stair-tool'
import { CurveWallTool } from './wall/curve-wall-tool'
import { WallTool } from './wall/wall-tool' import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool' import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -51,6 +52,7 @@ export const ToolManager: React.FC = () => {
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const editingHole = useEditor((state) => state.editingHole) const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
const buildingId = useViewer((state) => state.selection.buildingId) const buildingId = useViewer((state) => state.selection.buildingId)
@@ -140,6 +142,7 @@ export const ToolManager: React.FC = () => {
{showCeilingHoleEditor && selectedCeilingId && editingHole && ( {showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} /> <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)} )}
{curvingWall && <CurveWallTool node={curvingWall} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />} {movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {!movingNode && BuildToolComponent && <BuildToolComponent />}
</group> </group>
@@ -0,0 +1,157 @@
'use client'
import {
type AnyNodeId,
emitter,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingWall(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const localX = shiftPressedRef.current ? event.localPosition[0] : snap(event.localPosition[0])
const localZ = shiftPressedRef.current ? event.localPosition[2] : snap(event.localPosition[2])
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current ? offsetFromMidpoint : snap(offsetFromMidpoint)
const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)))
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -0,0 +1,307 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function rotateVector([x, z]: [number, number], angle: number): [number, number] {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return [x * cos - z * sin, x * sin + z * cos]
}
function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: [number, number]
end: [number, number]
}
function getLinkedWallSnapshots(args: {
wallId: WallNode['id']
wallParentId: string | null
originalStart: [number, number]
originalEnd: [number, number]
}) {
const { wallId, wallParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedWallSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'wall' && node.id !== wallId)) {
continue
}
if ((node.parentId ?? null) !== wallParentId) {
continue
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as [number, number],
end: [...node.end] as [number, number],
})
}
return snapshots
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
return linkedWalls.map((wall) => ({
id: wall.id,
start: samePoint(wall.start, originalStart)
? nextStart
: samePoint(wall.start, originalEnd)
? nextEnd
: wall.start,
end: samePoint(wall.end, originalStart)
? nextStart
: samePoint(wall.end, originalEnd)
? nextEnd
: wall.end,
}))
}
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const originalCenterRef = useRef<[number, number]>([
(node.start[0] + node.end[0]) / 2,
(node.start[1] + node.end[1]) / 2,
])
const originalHalfVectorRef = useRef<[number, number]>([
(node.end[0] - node.start[0]) / 2,
(node.end[1] - node.start[1]) / 2,
])
const linkedOriginalsRef = useRef(
getLinkedWallSnapshots({
wallId: node.id,
wallParentId: node.parentId ?? null,
originalStart: node.start,
originalEnd: node.end,
}),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const pendingRotationRef = useRef(0)
const shiftPressedRef = useRef(false)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const centerX = (node.start[0] + node.end[0]) / 2
const centerZ = (node.start[1] + node.end[1]) / 2
return [centerX, 0, centerZ]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const buildWallFromCenter = (center: [number, number]) => {
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]]
return { start: nextStart, end: nextEnd }
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
applyNodePreview([
{ id: nodeId, start: nextStart, end: nextEnd },
...getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
),
])
}
const restoreOriginal = () => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
}
const onGridMove = (event: GridEvent) => {
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
const localX = shiftPressedRef.current ? rawX : snap(rawX)
const localZ = shiftPressedRef.current ? rawZ : snap(rawZ)
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
const nextWall = buildWallFromCenter(nextCenter)
applyPreview(nextWall.start, nextWall.end)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true
useScene.temporal.getState().resume()
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
),
])
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta === 0) {
return
}
event.preventDefault()
pendingRotationRef.current += rotationDelta
sfxEmitter.emit('sfx:item-rotate')
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const currentCenter: [number, number] = [
(preview.start[0] + preview.end[0]) / 2,
(preview.start[1] + preview.end[1]) / 2,
]
const nextWall = buildWallFromCenter(currentCenter)
applyPreview(nextWall.start, nextWall.end)
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
shiftPressedRef.current = false
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitMoveMode])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,6 +1,7 @@
import { import {
type AnyNodeId, type AnyNodeId,
emitter, emitter,
isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -112,6 +113,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -168,6 +173,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -233,6 +242,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -1,6 +1,7 @@
import { import {
type AnyNodeId, type AnyNodeId,
emitter, emitter,
isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -86,6 +87,11 @@ export const WindowTool: React.FC = () => {
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
const levelId = getLevelId() const levelId = getLevelId()
if (!levelId) return if (!levelId) return
// Only interact with walls on the current level // Only interact with walls on the current level
@@ -135,6 +141,11 @@ export const WindowTool: React.FC = () => {
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -198,6 +209,7 @@ export const WindowTool: React.FC = () => {
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -1,73 +1,68 @@
'use client' 'use client'
import { DEFAULT_MATERIALS, type MaterialPreset, type MaterialSchema } from '@pascal-app/core' import {
getMaterialsForTarget,
toLibraryMaterialRef,
type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core'
import { useState } from 'react' import { useState } from 'react'
const PRESET_COLORS: Record<MaterialPreset, string> = {
white: '#ffffff',
brick: '#8b4513',
concrete: '#808080',
wood: '#deb887',
glass: '#87ceeb',
metal: '#c0c0c0',
plaster: '#f5f5dc',
tile: '#d3d3d3',
marble: '#fafafa',
custom: '#ffffff',
}
const PRESET_LABELS: Record<MaterialPreset, string> = {
white: 'White',
brick: 'Brick',
concrete: 'Concrete',
wood: 'Wood',
glass: 'Glass',
metal: 'Metal',
plaster: 'Plaster',
tile: 'Tile',
marble: 'Marble',
custom: 'Custom',
}
type MaterialPickerProps = { type MaterialPickerProps = {
nodeType?: MaterialTarget
value?: MaterialSchema value?: MaterialSchema
onChange: (material: MaterialSchema) => void selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
} }
export function MaterialPicker({ value, onChange }: MaterialPickerProps) { export function MaterialPicker({
const [showCustom, setShowCustom] = useState<boolean>( nodeType,
value?.preset === 'custom' || !!value?.properties, value,
) selectedMaterialPreset,
onChange,
onSelectMaterialPreset,
}: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
const currentPreset = value?.preset || 'white' const currentProps = value?.properties || {
const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset] color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
}
const selectedCatalogId =
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handlePresetChange = (preset: MaterialPreset) => { const handleCatalogSelect = (materialId: string) => {
if (preset === 'custom') { setShowCustom(false)
setShowCustom(true) onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
onChange({ }
preset: 'custom',
properties: { const handleCustomOpen = () => {
color: value?.properties?.color || '#ffffff', setShowCustom(true)
roughness: value?.properties?.roughness ?? 0.5, onChange?.({
metalness: value?.properties?.metalness ?? 0, preset: 'custom',
opacity: value?.properties?.opacity ?? 1, properties: {
transparent: value?.properties?.transparent ?? false, color: value?.properties?.color || '#ffffff',
side: value?.properties?.side ?? 'front', roughness: value?.properties?.roughness ?? 0.5,
}, metalness: value?.properties?.metalness ?? 0,
}) opacity: value?.properties?.opacity ?? 1,
} else { transparent: value?.properties?.transparent ?? false,
setShowCustom(false) side: value?.properties?.side ?? 'front',
onChange({ preset }) },
} })
} }
const handlePropertyChange = ( const handlePropertyChange = (
prop: keyof typeof currentProps, prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps], val: (typeof currentProps)[keyof typeof currentProps],
) => { ) => {
onChange({ onChange?.({
preset: showCustom ? 'custom' : currentPreset, preset: 'custom',
properties: { properties: {
...currentProps, ...currentProps,
[prop]: val, [prop]: val,
@@ -77,31 +72,56 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-5 gap-1.5"> {(catalogItems.length > 0 || onChange) && (
{(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => ( <div className="space-y-2">
<button {catalogItems.length > 0 ? (
className={`h-8 w-8 rounded border-2 transition-all ${ <div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
currentPreset === preset ) : null}
? 'border-blue-500 ring-2 ring-blue-500/30' <div className="flex flex-wrap gap-1.5">
: 'border-gray-300 hover:border-gray-400' {catalogItems.map((item) => (
}`} <button
key={preset} className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
onClick={() => handlePresetChange(preset)} selectedCatalogId === toLibraryMaterialRef(item.id)
style={{ ? 'border-blue-500 ring-2 ring-blue-500/30'
backgroundColor: PRESET_COLORS[preset], : 'border-gray-300 hover:border-gray-400'
backgroundImage: }`}
preset === 'glass' key={item.id}
? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)' onClick={() => handleCatalogSelect(item.id)}
: undefined, title={item.label}
backgroundSize: preset === 'glass' ? '8px 8px' : undefined, type="button"
}} >
title={PRESET_LABELS[preset]} {item.previewThumbnailUrl ? (
type="button" <img
/> alt={item.label}
))} className="h-full w-full object-cover"
</div> src={item.previewThumbnailUrl}
/>
) : item.previewColor ? (
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
) : (
<div className="h-full w-full bg-gray-100" />
)}
</button>
))}
{onChange ? (
<button
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
showCustom
? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
: 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
}`}
onClick={handleCustomOpen}
title="Custom"
type="button"
>
Custom
</button>
) : null}
</div>
</div>
)}
{showCustom && ( {showCustom && onChange && (
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Color</label> <label className="w-16 text-gray-500 text-xs">Color</label>
@@ -2,10 +2,11 @@
import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core' import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' 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 { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' 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 { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -18,6 +19,7 @@ export function CeilingPanel() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole) const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId const node = selectedId
@@ -34,7 +36,14 @@ export function CeilingPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -102,6 +111,13 @@ export function CeilingPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [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 if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => { const calculateArea = (polygon: Array<[number, number]>): number => {
@@ -223,8 +239,17 @@ export function CeilingPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="ceiling"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -1,8 +1,17 @@
'use client' 'use client'
import { type AnyNode, type AnyNodeId, type FenceBaseStyle, type FenceNode, type FenceStyle, useScene } from '@pascal-app/core' import {
type AnyNode,
type AnyNodeId,
type FenceBaseStyle,
type FenceNode,
type FenceStyle,
type MaterialSchema,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react' import { useCallback } from 'react'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -62,6 +71,20 @@ export function FencePanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null
const dx = node.end[0] - node.start[0] const dx = node.end[0] - node.start[0]
@@ -179,6 +202,16 @@ export function FencePanel() {
value={node.edgeInset} value={node.edgeInset}
/> />
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="fence"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -17,7 +17,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
@@ -43,7 +42,14 @@ export function RoofPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -166,7 +172,7 @@ export function RoofPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Position"> <PanelSection title="Position">
<MetricControl <SliderControl
label="X" label="X"
max={50} max={50}
min={-50} min={-50}
@@ -180,7 +186,7 @@ export function RoofPanel() {
unit="m" unit="m"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Y" label="Y"
max={50} max={50}
min={-50} min={-50}
@@ -194,7 +200,7 @@ export function RoofPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Z" label="Z"
max={50} max={50}
min={-50} min={-50}
@@ -255,7 +261,13 @@ export function RoofPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="roof"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -16,7 +16,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -57,7 +56,14 @@ export function RoofSegmentPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -230,7 +236,7 @@ export function RoofSegmentPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Position"> <PanelSection title="Position">
<MetricControl <SliderControl
label="X" label="X"
max={50} max={50}
min={-50} min={-50}
@@ -244,7 +250,7 @@ export function RoofSegmentPanel() {
unit="m" unit="m"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Y" label="Y"
max={50} max={50}
min={-50} min={-50}
@@ -258,7 +264,7 @@ export function RoofSegmentPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Z" label="Z"
max={50} max={50}
min={-50} min={-50}
@@ -319,7 +325,13 @@ export function RoofSegmentPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="roof-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -2,8 +2,9 @@
import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core' import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' 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 { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
@@ -18,6 +19,7 @@ export function SlabPanel() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole) const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
@@ -30,9 +32,16 @@ export function SlabPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback( const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -100,6 +109,13 @@ export function SlabPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [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 if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => { const calculateArea = (polygon: Array<[number, number]>): number => {
@@ -221,8 +237,17 @@ export function SlabPanel() {
</div> </div>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="slab"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -21,7 +21,6 @@ import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -70,7 +69,14 @@ export function StairPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -361,7 +367,7 @@ export function StairPanel() {
)} )}
<PanelSection title="Position"> <PanelSection title="Position">
<MetricControl <SliderControl
label="X" label="X"
max={50} max={50}
min={-50} min={-50}
@@ -375,7 +381,7 @@ export function StairPanel() {
unit="m" unit="m"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Y" label="Y"
max={50} max={50}
min={-50} min={-50}
@@ -389,7 +395,7 @@ export function StairPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Z" label="Z"
max={50} max={50}
min={-50} min={-50}
@@ -470,7 +476,13 @@ export function StairPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="stair"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -17,7 +17,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -65,7 +64,14 @@ export function StairSegmentPanel() {
const handleMaterialChange = useCallback( const handleMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -243,7 +249,7 @@ export function StairSegmentPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Position"> <PanelSection title="Position">
<MetricControl <SliderControl
label="X" label="X"
max={50} max={50}
min={-50} min={-50}
@@ -257,7 +263,7 @@ export function StairSegmentPanel() {
unit="m" unit="m"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Y" label="Y"
max={50} max={50}
min={-50} min={-50}
@@ -271,7 +277,7 @@ export function StairSegmentPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Z" label="Z"
max={50} max={50}
min={-50} min={-50}
@@ -332,7 +338,13 @@ export function StairSegmentPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="stair-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
@@ -3,12 +3,20 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
normalizeWallCurveOffset,
type MaterialSchema, type MaterialSchema,
useScene, useScene,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from '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 { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -19,6 +27,8 @@ export function WallPanel() {
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined
@@ -55,9 +65,16 @@ export function WallPanel() {
[node, handleUpdate], [node, handleUpdate],
) )
const handleMaterialChange = useCallback( const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material, materialPreset: undefined })
}, },
[handleUpdate], [handleUpdate],
) )
@@ -66,14 +83,40 @@ export function WallPanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [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 if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
const dx = node.end[0] - node.start[0] const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1] 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 height = node.height ?? 2.5
const thickness = node.thickness ?? 0.1 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 ( return (
<PanelWrapper <PanelWrapper
@@ -113,11 +156,40 @@ export function WallPanel() {
unit="m" unit="m"
value={Math.round(thickness * 1000) / 1000} value={Math.round(thickness * 1000) / 1000}
/> />
{!hasWallChildrenBlockingCurve && (
<SliderControl
label="Curve"
max={Math.max(0.01, maxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)}
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
precision={2}
step={0.01}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
)}
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} /> <MaterialPicker
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection> </PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
</PanelWrapper> </PanelWrapper>
) )
} }
+13
View File
@@ -3,16 +3,19 @@
import type { AssetInput } from '@pascal-app/core' import type { AssetInput } from '@pascal-app/core'
import { import {
type BuildingNode, type BuildingNode,
type CeilingNode,
type DoorNode, type DoorNode,
type FenceNode, type FenceNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type SlabNode,
type Space, type Space,
type StairNode, type StairNode,
type StairSegmentNode, type StairSegmentNode,
useScene, useScene,
type WallNode,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -88,6 +91,9 @@ type EditorState = {
| WindowNode | WindowNode
| DoorNode | DoorNode
| FenceNode | FenceNode
| CeilingNode
| SlabNode
| WallNode
| RoofNode | RoofNode
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
@@ -100,6 +106,9 @@ type EditorState = {
| WindowNode | WindowNode
| DoorNode | DoorNode
| FenceNode | FenceNode
| CeilingNode
| SlabNode
| WallNode
| RoofNode | RoofNode
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
@@ -107,6 +116,8 @@ type EditorState = {
| BuildingNode | BuildingNode
| null, | null,
) => void ) => void
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode // Space detection for cutaway mode
@@ -437,6 +448,8 @@ const useEditor = create<EditorState>()(
| BuildingNode | BuildingNode
| null, | null,
setMovingNode: (node) => set({ movingNode: node }), setMovingNode: (node) => set({ movingNode: node }),
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {}, spaces: {},
@@ -1,4 +1,4 @@
import { type CeilingNode, resolveMaterial, useRegistry } from '@pascal-app/core' import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
@@ -39,10 +39,11 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const handlers = useNodeEvents(node, 'ceiling') const handlers = useNodeEvents(node, 'ceiling')
const materials = useMemo(() => { const materials = useMemo(() => {
const props = resolveMaterial(node.material) const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material)
const color = props.color || '#999999' const color = props.color || '#999999'
return createCeilingMaterials(color) return createCeilingMaterials(color)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return ( return (
<mesh material={materials.bottomMaterial} ref={ref}> <mesh material={materials.bottomMaterial} ref={ref}>
@@ -2,12 +2,28 @@ import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
export const FenceRenderer = ({ node }: { node: FenceNode }) => { export const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
const handlers = useNodeEvents(node, 'fence') const handlers = useNodeEvents(node, 'fence')
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, []) const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
useRegistry(node.id, 'fence', ref) useRegistry(node.id, 'fence', ref)
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -1,24 +1,43 @@
import { type RoofSegmentNode, useRegistry } from '@pascal-app/core' import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import type * as THREE from 'three' import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial } from '../../../lib/materials' import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!) const ref = useRef<THREE.Mesh>(null!)
const nodes = useScene((state) => state.nodes)
useRegistry(node.id, 'roof-segment', ref) useRegistry(node.id, 'roof-segment', ref)
const handlers = useNodeEvents(node, 'roof-segment') const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const parentNode =
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
const customMaterial = useMemo(() => { const customMaterial = useMemo(() => {
const mat = node.material const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
const effectiveMaterial = node.material ?? parentNode?.material
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return null if (!mat) return null
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
@@ -2,7 +2,7 @@ import { type RoofNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import type * as THREE from 'three' import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial } from '../../../lib/materials' import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
import { roofDebugMaterials, roofMaterials } from './roof-materials' import { roofDebugMaterials, roofMaterials } from './roof-materials'
@@ -16,10 +16,12 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const customMaterial = useMemo(() => { const customMaterial = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material const mat = node.material
if (!mat) return null if (!mat) return null
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
@@ -1,8 +1,13 @@
import { type SlabNode, useRegistry } from '@pascal-app/core' 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 type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_SLAB_MATERIAL } from '../../../lib/materials' import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_SLAB_MATERIAL,
} from '../../../lib/materials'
export const SlabRenderer = ({ node }: { node: SlabNode }) => { export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
@@ -12,10 +17,34 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const handlers = useNodeEvents(node, 'slab') const handlers = useNodeEvents(node, 'slab')
const material = useMemo(() => { const material = useMemo(() => {
const mat = node.material const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (!mat) return DEFAULT_SLAB_MATERIAL const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL)
return createMaterial(mat) const slabMaterial = sourceMaterial.clone()
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
// 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 ( return (
<mesh <mesh
@@ -1,11 +1,12 @@
import { type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!) const ref = useRef<THREE.Mesh>(null!)
const nodes = useScene((state) => state.nodes)
useRegistry(node.id, 'stair-segment', ref) useRegistry(node.id, 'stair-segment', ref)
@@ -14,12 +15,30 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
}, [node.id]) }, [node.id])
const handlers = useNodeEvents(node, 'stair-segment') const handlers = useNodeEvents(node, 'stair-segment')
const parentNode =
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
const material = useMemo(() => { const material = useMemo(() => {
const mat = node.material const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
const effectiveMaterial = node.material ?? parentNode?.material
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return DEFAULT_STAIR_MATERIAL if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
])
return ( return (
<mesh <mesh
@@ -2,7 +2,7 @@ import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, use
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
type SegmentTransform = { type SegmentTransform = {
@@ -47,10 +47,12 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
const handlers = useNodeEvents(node, 'stair') const handlers = useNodeEvents(node, 'stair')
const material = useMemo(() => { const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return ( return (
<group <group
@@ -2,7 +2,11 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_WALL_MATERIAL } from '../../../lib/materials' import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_WALL_MATERIAL,
} from '../../../lib/materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
export const WallRenderer = ({ node }: { node: WallNode }) => { export const WallRenderer = ({ node }: { node: WallNode }) => {
@@ -17,10 +21,18 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
const handlers = useNodeEvents(node, 'wall') const handlers = useNodeEvents(node, 'wall')
const material = useMemo(() => { const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material const mat = node.material
if (!mat) return DEFAULT_WALL_MATERIAL if (!mat) return DEFAULT_WALL_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.materialPreset,
])
return ( return (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}> <mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
@@ -281,6 +281,10 @@ export const SelectionManager = () => {
if (!strategy) return if (!strategy) return
if (strategy.isValid(event.node)) { if (strategy.isValid(event.node)) {
event.stopPropagation() event.stopPropagation()
if (event.node.type === 'slab') {
useViewer.setState({ hoveredId: null })
return
}
useViewer.setState({ hoveredId: event.node.id }) useViewer.setState({ hoveredId: event.node.id })
} }
} }
@@ -388,11 +392,14 @@ const OutlinerSync = () => {
const selection = useViewer((s) => s.selection) const selection = useViewer((s) => s.selection)
const hoveredId = useViewer((s) => s.hoveredId) const hoveredId = useViewer((s) => s.hoveredId)
const outliner = useViewer((s) => s.outliner) const outliner = useViewer((s) => s.outliner)
const nodes = useScene((s) => s.nodes)
useEffect(() => { useEffect(() => {
// Sync selected objects // Sync selected objects
outliner.selectedObjects.length = 0 outliner.selectedObjects.length = 0
for (const id of selection.selectedIds) { for (const id of selection.selectedIds) {
const node = nodes[id as AnyNodeId]
if (node?.type === 'slab') continue
const obj = sceneRegistry.nodes.get(id) const obj = sceneRegistry.nodes.get(id)
if (obj) outliner.selectedObjects.push(obj) if (obj) outliner.selectedObjects.push(obj)
} }
@@ -400,10 +407,12 @@ const OutlinerSync = () => {
// Sync hovered objects // Sync hovered objects
outliner.hoveredObjects.length = 0 outliner.hoveredObjects.length = 0
if (hoveredId) { if (hoveredId) {
const hoveredNode = nodes[hoveredId as AnyNodeId]
if (hoveredNode?.type === 'slab') return
const obj = sceneRegistry.nodes.get(hoveredId) const obj = sceneRegistry.nodes.get(hoveredId)
if (obj) outliner.hoveredObjects.push(obj) if (obj) outliner.hoveredObjects.push(obj)
} }
}, [selection, hoveredId, outliner]) }, [selection, hoveredId, outliner, nodes])
return null return null
} }
+246 -4
View File
@@ -1,4 +1,11 @@
import { type MaterialProperties, type MaterialSchema, resolveMaterial } from '@pascal-app/core' import {
type MaterialMapProperties,
type MaterialPresetPayload,
type MaterialProperties,
type MaterialSchema,
getMaterialPresetByRef,
resolveMaterial,
} from '@pascal-app/core'
import * as THREE from 'three' import * as THREE from 'three'
const sideMap: Record<MaterialProperties['side'], THREE.Side> = { const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
@@ -8,19 +15,247 @@ const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
} }
const materialCache = new Map<string, THREE.MeshStandardMaterial>() const materialCache = new Map<string, THREE.MeshStandardMaterial>()
const textureCache = new Map<string, THREE.Texture>()
const textureLoadPromises = new Map<string, Promise<THREE.Texture | null>>()
const textureLoader = new THREE.TextureLoader()
const wrapMap = {
Repeat: THREE.RepeatWrapping,
ClampToEdge: THREE.ClampToEdgeWrapping,
MirroredRepeat: THREE.MirroredRepeatWrapping,
} as const
type StandardMaterial = THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial
type TextureSlot =
| 'map'
| 'normalMap'
| 'roughnessMap'
| 'metalnessMap'
| 'displacementMap'
| 'aoMap'
| 'bumpMap'
| 'alphaMap'
| 'lightMap'
| 'emissiveMap'
const SRGB_TEXTURE_SLOTS: TextureSlot[] = ['map', 'emissiveMap']
function getCacheKey(props: MaterialProperties): string { function getCacheKey(props: MaterialProperties): string {
return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}` return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}`
} }
export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial { function getTextureKey(material?: MaterialSchema): string {
const props = resolveMaterial(material) const texture = material?.texture
const cacheKey = getCacheKey(props) if (!texture) return 'none'
const repeat = texture.repeat?.join('x') ?? 'default'
const scale = texture.scale ?? 'default'
return `${texture.url}-${repeat}-${scale}`
}
function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
const textureConfig = material?.texture
if (!textureConfig?.url) return undefined
const cacheKey = getTextureKey(material)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(textureConfig.url)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
const repeatX = textureConfig.repeat?.[0] ?? textureConfig.scale ?? 1
const repeatY = textureConfig.repeat?.[1] ?? textureConfig.scale ?? 1
texture.repeat.set(repeatX, repeatY)
texture.colorSpace = THREE.SRGBColorSpace
textureCache.set(cacheKey, texture)
return texture
}
function isStandardMaterial(material: THREE.Material): material is StandardMaterial {
return (
material instanceof THREE.MeshStandardMaterial ||
material instanceof THREE.MeshPhysicalMaterial
)
}
function applyTextureProperties(
texture: THREE.Texture,
props: MaterialMapProperties,
slot?: TextureSlot,
): THREE.Texture {
texture.wrapS = wrapMap[props.wrapS]
texture.wrapT = wrapMap[props.wrapT]
texture.repeat.set(props.repeatX, props.repeatY)
texture.rotation = props.rotation
texture.flipY = props.flipY
texture.colorSpace = SRGB_TEXTURE_SLOTS.includes(slot ?? 'map')
? THREE.SRGBColorSpace
: THREE.NoColorSpace
texture.needsUpdate = true
return texture
}
function getPresetTextureCacheKey(path: string, props: MaterialMapProperties, slot?: TextureSlot): string {
return `${path}-${props.repeatX}-${props.repeatY}-${props.rotation}-${props.wrapS}-${props.wrapT}-${props.flipY}-${slot ?? 'map'}`
}
function getPresetTexture(path: string, props: MaterialMapProperties, slot?: TextureSlot): THREE.Texture {
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(path)
applyTextureProperties(texture, props, slot)
textureCache.set(cacheKey, texture)
return texture
}
async function loadPresetTexture(
path: string,
props: MaterialMapProperties,
slot?: TextureSlot,
): Promise<THREE.Texture | null> {
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const existingPromise = textureLoadPromises.get(cacheKey)
if (existingPromise) return existingPromise
const promise = textureLoader
.loadAsync(path)
.then((texture) => {
applyTextureProperties(texture, props, slot)
textureCache.set(cacheKey, texture)
textureLoadPromises.delete(cacheKey)
return texture
})
.catch((error) => {
console.warn('[viewer] Failed to load material texture', path, error)
textureLoadPromises.delete(cacheKey)
return null
})
textureLoadPromises.set(cacheKey, promise)
return promise
}
function queueTextureAssignment(
material: StandardMaterial,
slot: TextureSlot,
path: string | undefined,
props: MaterialMapProperties,
) {
if (!path) {
material[slot] = null
return
}
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) {
material[slot] = cached
return
}
material[slot] = null
void loadPresetTexture(path, props, slot).then((texture) => {
if (!texture) return
material[slot] = texture
material.needsUpdate = true
})
}
function applyMaterialMapProperties(material: StandardMaterial, mapProperties: MaterialMapProperties) {
material.color.set(mapProperties.color)
material.roughness = mapProperties.roughness
material.metalness = mapProperties.metalness
material.emissiveIntensity = mapProperties.emissiveIntensity
material.emissive.set(mapProperties.emissiveColor)
material.displacementScale = mapProperties.displacementScale
material.bumpScale = mapProperties.bumpScale
material.aoMapIntensity = mapProperties.aoMapIntensity
material.lightMapIntensity = mapProperties.lightMapIntensity
material.transparent = mapProperties.transparent
material.opacity = mapProperties.opacity
material.side =
mapProperties.side === 0
? THREE.FrontSide
: mapProperties.side === 1
? THREE.BackSide
: THREE.DoubleSide
material.normalScale.set(mapProperties.normalScaleX, mapProperties.normalScaleY)
material.needsUpdate = true
}
function applyMaterialPresetTextures(
material: StandardMaterial,
preset: MaterialPresetPayload,
) {
const { maps, mapProperties } = preset
queueTextureAssignment(material, 'map', maps.albedoMap, mapProperties)
queueTextureAssignment(material, 'normalMap', maps.normalMap, mapProperties)
queueTextureAssignment(material, 'roughnessMap', maps.roughnessMap, mapProperties)
queueTextureAssignment(material, 'metalnessMap', maps.metalnessMap, mapProperties)
queueTextureAssignment(material, 'displacementMap', maps.displacementMap, mapProperties)
queueTextureAssignment(material, 'aoMap', maps.aoMap, mapProperties)
queueTextureAssignment(material, 'bumpMap', maps.bumpMap, mapProperties)
queueTextureAssignment(material, 'alphaMap', maps.alphaMap, mapProperties)
queueTextureAssignment(material, 'lightMap', maps.lightMap, mapProperties)
queueTextureAssignment(material, 'emissiveMap', maps.emissiveMap, mapProperties)
material.needsUpdate = true
}
export function applyMaterialPresetToMaterials(
materialInput: THREE.Material | THREE.Material[],
preset: MaterialPresetPayload | null | undefined,
) {
if (!preset) return
const materials = (Array.isArray(materialInput) ? materialInput : [materialInput]).filter(
isStandardMaterial,
)
if (materials.length === 0) return
for (const material of materials) {
applyMaterialMapProperties(material, preset.mapProperties)
applyMaterialPresetTextures(material, preset)
}
}
export function createMaterialFromPreset(preset: MaterialPresetPayload): THREE.MeshStandardMaterial {
const cacheKey = JSON.stringify(preset)
if (materialCache.has(cacheKey)) { if (materialCache.has(cacheKey)) {
return materialCache.get(cacheKey)! return materialCache.get(cacheKey)!
} }
const material = new THREE.MeshStandardMaterial()
applyMaterialPresetToMaterials(material, preset)
materialCache.set(cacheKey, material)
return material
}
export function createMaterialFromPresetRef(materialPreset?: string): THREE.MeshStandardMaterial | null {
const preset = getMaterialPresetByRef(materialPreset)
if (!preset) return null
return createMaterialFromPreset(preset)
}
export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial {
const props = resolveMaterial(material)
const cacheKey = `${getCacheKey(props)}-${getTextureKey(material)}`
if (materialCache.has(cacheKey)) {
return materialCache.get(cacheKey)!
}
const map = getTexture(material)
const threeMaterial = new THREE.MeshStandardMaterial({ const threeMaterial = new THREE.MeshStandardMaterial({
color: props.color, color: props.color,
roughness: props.roughness, roughness: props.roughness,
@@ -28,6 +263,7 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
opacity: props.opacity, opacity: props.opacity,
transparent: props.transparent, transparent: props.transparent,
side: sideMap[props.side], side: sideMap[props.side],
map,
}) })
materialCache.set(cacheKey, threeMaterial) materialCache.set(cacheKey, threeMaterial)
@@ -70,4 +306,10 @@ export function clearMaterialCache(): void {
material.dispose() material.dispose()
} }
materialCache.clear() materialCache.clear()
for (const texture of textureCache.values()) {
texture.dispose()
}
textureCache.clear()
textureLoadPromises.clear()
} }
@@ -2,6 +2,7 @@ import {
type AnyNodeId, type AnyNodeId,
baseMaterial, baseMaterial,
emitter, emitter,
getMaterialPresetByRef,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallNode, type WallNode,
@@ -13,6 +14,7 @@ import { Color } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
const tmpVec = new Vector3() const tmpVec = new Vector3()
const u = new Vector3() const u = new Vector3()
@@ -21,12 +23,14 @@ const DEFAULT_WALL_COLOR = '#f2f0ed'
const WALL_HIGHLIGHT_PROFILES = { const WALL_HIGHLIGHT_PROFILES = {
delete: { delete: {
color: new Color('#dc2626'), color: new Color('#dc2626'),
blend: 0.78, blend: 0.76,
emissiveBlend: 0.92,
emissiveIntensity: 0.46, emissiveIntensity: 0.46,
}, },
selection: { selection: {
color: new Color('#818cf8'), color: new Color('#818cf8'),
blend: 0.32, blend: 0.32,
emissiveBlend: 0.7,
emissiveIntensity: 0.42, emissiveIntensity: 0.42,
}, },
} as const } as const
@@ -51,11 +55,11 @@ const dotPattern = Fn(() => {
}) })
interface WallMaterials { interface WallMaterials {
visible: MeshStandardNodeMaterial visible: Material
invisible: MeshStandardNodeMaterial invisible: MeshStandardNodeMaterial
deleteVisible: MeshStandardNodeMaterial deleteVisible: Material
deleteInvisible: MeshStandardNodeMaterial deleteInvisible: MeshStandardNodeMaterial
highlightedVisible: MeshStandardNodeMaterial highlightedVisible: Material
highlightedInvisible: MeshStandardNodeMaterial highlightedInvisible: MeshStandardNodeMaterial
materialHash: string materialHash: string
} }
@@ -75,6 +79,7 @@ const presetColors = {
} as const } as const
function getMaterialHash(wallNode: WallNode): string { function getMaterialHash(wallNode: WallNode): string {
if (wallNode.materialPreset) return `preset-ref-${wallNode.materialPreset}`
if (!wallNode.material) return 'none' if (!wallNode.material) return 'none'
const mat = wallNode.material const mat = wallNode.material
if (mat.preset && mat.preset !== 'custom') { if (mat.preset && mat.preset !== 'custom') {
@@ -90,27 +95,54 @@ function getPresetColor(preset: string): string {
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff' return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'
} }
function getHighlightedColor(color: string, kind: WallHighlightKind): Color { function getHighlightedColor(color: Color, kind: WallHighlightKind): Color {
const profile = WALL_HIGHLIGHT_PROFILES[kind] const profile = WALL_HIGHLIGHT_PROFILES[kind]
return new Color(color).lerp(profile.color, profile.blend) return color.clone().lerp(profile.color, profile.blend)
} }
function createHighlightedWallMaterial( function createHighlightedWallMaterial(
material: MeshStandardNodeMaterial, material: Material,
baseColor: string,
kind: WallHighlightKind, kind: WallHighlightKind,
): MeshStandardNodeMaterial { ): Material {
const highlightedMaterial = material.clone() const highlightedMaterial = material.clone() as Material & {
const highlightedColor = getHighlightedColor(baseColor, kind) color?: Color
emissive?: Color
emissiveIntensity?: number
needsUpdate?: boolean
}
const profile = WALL_HIGHLIGHT_PROFILES[kind] const profile = WALL_HIGHLIGHT_PROFILES[kind]
highlightedMaterial.color = highlightedColor if ('color' in highlightedMaterial && highlightedMaterial.color) {
highlightedMaterial.emissive = highlightedColor.clone() highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind)
highlightedMaterial.emissiveIntensity = profile.emissiveIntensity }
if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) {
highlightedMaterial.emissive = highlightedMaterial.emissive
.clone()
.lerp(profile.color, profile.emissiveBlend)
}
if ('emissiveIntensity' in highlightedMaterial) {
highlightedMaterial.emissiveIntensity = Math.max(
highlightedMaterial.emissiveIntensity ?? 0,
profile.emissiveIntensity,
)
}
highlightedMaterial.needsUpdate = true
return highlightedMaterial return highlightedMaterial
} }
function createBaseVisibleWallMaterial(wallNode: WallNode): Material {
if (wallNode.materialPreset) {
return createMaterialFromPresetRef(wallNode.materialPreset) ?? baseMaterial
}
if (wallNode.material) {
return createMaterial(wallNode.material)
}
return baseMaterial
}
function getMaterialsForWall(wallNode: WallNode): WallMaterials { function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id const cacheKey = wallNode.id
const materialHash = getMaterialHash(wallNode) const materialHash = getMaterialHash(wallNode)
@@ -130,19 +162,16 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
} }
let userColor = DEFAULT_WALL_COLOR let userColor = DEFAULT_WALL_COLOR
if (wallNode.material?.properties?.color) { const preset = getMaterialPresetByRef(wallNode.materialPreset)
if (preset?.mapProperties?.color) {
userColor = preset.mapProperties.color
} else if (wallNode.material?.properties?.color) {
userColor = wallNode.material.properties.color userColor = wallNode.material.properties.color
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') { } else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
userColor = getPresetColor(wallNode.material.preset) userColor = getPresetColor(wallNode.material.preset)
} }
const visibleMat = wallNode.material const visibleMat = createBaseVisibleWallMaterial(wallNode)
? new MeshStandardNodeMaterial({
color: userColor,
roughness: 1,
metalness: 0,
})
: (baseMaterial.clone() as MeshStandardNodeMaterial)
const invisibleMat = new MeshStandardNodeMaterial({ const invisibleMat = new MeshStandardNodeMaterial({
transparent: true, transparent: true,
@@ -152,10 +181,13 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
emissive: userColor, emissive: userColor,
}) })
const highlightedVisible = createHighlightedWallMaterial(visibleMat, userColor, 'selection') const highlightedVisible = createHighlightedWallMaterial(visibleMat, 'selection')
const highlightedInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'selection') const highlightedInvisible = createHighlightedWallMaterial(
const deleteVisible = createHighlightedWallMaterial(visibleMat, userColor, 'delete') invisibleMat,
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'delete') 'selection',
) as MeshStandardNodeMaterial
const deleteVisible = createHighlightedWallMaterial(visibleMat, 'delete')
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, 'delete') as MeshStandardNodeMaterial
const result: WallMaterials = { const result: WallMaterials = {
visible: visibleMat, visible: visibleMat,
@@ -170,6 +202,10 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
return result return result
} }
function getVisibleWallMaterial(wallNode: WallNode): Material {
return createBaseVisibleWallMaterial(wallNode)
}
function getWallHideState( function getWallHideState(
wallNode: WallNode, wallNode: WallNode,
wallMesh: Mesh, wallMesh: Mesh,
@@ -265,9 +301,7 @@ export const WallCutout = () => {
? materials.deleteVisible ? materials.deleteVisible
: isSelectionHighlighted : isSelectionHighlighted
? materials.highlightedVisible ? materials.highlightedVisible
: wallNode.material : getVisibleWallMaterial(wallNode)
? materials.visible
: baseMaterial
} }
}) })
lastWallMode.current = wallMode lastWallMode.current = wallMode
@@ -289,7 +323,7 @@ export const WallCutout = () => {
const current = wallMesh.material as Material const current = wallMesh.material as Material
snapshot.set(wallMesh, current) snapshot.set(wallMesh, current)
if (current === mats.highlightedVisible || current === mats.deleteVisible) { if (current === mats.highlightedVisible || current === mats.deleteVisible) {
wallMesh.material = mats.visible wallMesh.material = getVisibleWallMaterial(wallNode)
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
wallMesh.material = mats.invisible wallMesh.material = mats.invisible
} }